@browserstack/mcp-server 2.2.0-beta.2 → 2.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.
- package/README.md +42 -432
- package/capability/loadtesting.capability-index.json +3001 -0
- package/capability/tm.capability-index.json +1 -2
- package/dist/tools/ask-browserstack/central-oauth.d.ts +46 -25
- package/dist/tools/ask-browserstack/central-oauth.js +177 -55
- package/dist/tools/capability-registry/bind.js +7 -43
- package/dist/tools/capability-registry/discovery.js +1 -3
- package/dist/tools/capability-registry/egress.d.ts +5 -12
- package/dist/tools/capability-registry/egress.js +1 -24
- package/dist/tools/capability-registry/register.js +41 -289
- package/dist/tools/capability-registry/resolve.d.ts +1 -1
- package/dist/tools/capability-registry/resolve.js +3 -24
- package/dist/tools/capability-registry/search.d.ts +1 -28
- package/dist/tools/capability-registry/search.js +4 -202
- package/dist/tools/capability-registry/types.d.ts +3 -42
- package/dist/tools/testmanagement.js +5 -5
- package/dist/tools/tool-handoff.d.ts +12 -15
- package/dist/tools/tool-handoff.js +18 -21
- package/package.json +3 -4
- package/capability/tra.capability-index.json +0 -52096
- package/dist/lib/central-oauth.d.ts +0 -133
- package/dist/lib/central-oauth.js +0 -246
- package/dist/tools/capability-registry/oauth.d.ts +0 -63
- package/dist/tools/capability-registry/oauth.js +0 -176
- package/dist/tools/capability-registry/routing-token.d.ts +0 -25
- package/dist/tools/capability-registry/routing-token.js +0 -87
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mint a BrowserStack central-OAuth JWT from the caller's username and access key.
|
|
3
|
-
*
|
|
4
|
-
* WHAT IS MINTED IS THE CALLER, NOT A SERVICE ACCOUNT. The exchange takes the user's own
|
|
5
|
-
* credentials and returns a JWT whose `user` claim carries their `user_id`/`group_id`, so a
|
|
6
|
-
* product authorises against signed claims rather than against anything we put in a request
|
|
7
|
-
* body. Scopes narrow it further. The property this preserves is the one the whole surface
|
|
8
|
-
* rests on: an agent can never do what the user could not.
|
|
9
|
-
*
|
|
10
|
-
* SECRET HYGIENE IS THE WHOLE POINT OF THIS MODULE, and Atlas's `central_oauth.py` learned
|
|
11
|
-
* it the hard way: "The body can echo the credential back on some errors, so it is NOT
|
|
12
|
-
* logged or raised — only the status." Neither the access key nor the minted token is ever
|
|
13
|
-
* logged, returned in an error, or put in a message. Only a status code is.
|
|
14
|
-
*
|
|
15
|
-
* THIS MODULE IS THE MECHANISM AND NOTHING ELSE. It throws `CentralAuthError` carrying a
|
|
16
|
-
* `kind`, and each caller words its own message from that: `ask-browserstack` tells the
|
|
17
|
-
* reader nothing reached the agent, the capability registry tells them which product could
|
|
18
|
-
* not be reached. Sharing the mechanism is what stops a second, subtly different minter
|
|
19
|
-
* existing; sharing the prose would put Atlas's wording in front of a registry user.
|
|
20
|
-
*/
|
|
21
|
-
export interface Credentials {
|
|
22
|
-
username: string;
|
|
23
|
-
accessKey: string;
|
|
24
|
-
}
|
|
25
|
-
export interface TokenResponse {
|
|
26
|
-
status: number;
|
|
27
|
-
body: unknown;
|
|
28
|
-
/** Only when there was no response at all to speak for itself. */
|
|
29
|
-
error?: string;
|
|
30
|
-
}
|
|
31
|
-
export type TokenTransport = (url: string, form: Record<string, string>) => Promise<TokenResponse>;
|
|
32
|
-
/** What we ask for. The endpoint clamps to its own maximum, so the response wins. */
|
|
33
|
-
export declare const REQUESTED_EXPIRES_IN = 3600;
|
|
34
|
-
/**
|
|
35
|
-
* The token endpoint gets its own, short budget — it is not the call being made.
|
|
36
|
-
*
|
|
37
|
-
* READ AT CALL TIME, NEVER IN A DEFAULT PARAMETER — see `fetchTokenTransport`.
|
|
38
|
-
*/
|
|
39
|
-
export declare const TOKEN_TIMEOUT_MS = 15000;
|
|
40
|
-
/**
|
|
41
|
-
* How a mint failed, in the terms a reader can act on.
|
|
42
|
-
*
|
|
43
|
-
* `refused_scope` is a provisioning problem, `refused_credentials` is "your password is
|
|
44
|
-
* wrong", `server_error` is "auth is down" and `unreachable` is "we could not get there".
|
|
45
|
-
* Four different fixes, so they must not collapse into one — routing a 503 to "your
|
|
46
|
-
* credentials were rejected" has already sent someone to audit env vars that had worked
|
|
47
|
-
* minutes earlier.
|
|
48
|
-
*/
|
|
49
|
-
export type AuthFailureKind = "no_credentials" | "unreachable" | "server_error" | "refused_scope" | "refused_credentials" | "unusable";
|
|
50
|
-
/** Carries the classification and the status. NEVER any part of the response body. */
|
|
51
|
-
export declare class CentralAuthError extends Error {
|
|
52
|
-
readonly kind: AuthFailureKind;
|
|
53
|
-
readonly status: number;
|
|
54
|
-
readonly scope: string;
|
|
55
|
-
constructor(kind: AuthFailureKind, status: number, scope: string);
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* Was this refusal about the SCOPE or about the CREDENTIAL?
|
|
59
|
-
*
|
|
60
|
-
* The two need completely different fixes — provisioning versus a password — so collapsing
|
|
61
|
-
* them into one message sends someone to the wrong place entirely. Our form has five fields
|
|
62
|
-
* and four of them are constants, so a refusal of the REQUEST (as opposed to the caller) can
|
|
63
|
-
* only really be about the scope.
|
|
64
|
-
*
|
|
65
|
-
* Nothing from the body is ever surfaced; the code is used to classify and then discarded.
|
|
66
|
-
*/
|
|
67
|
-
export declare function refusalIsAboutScope(status: number, body: unknown): boolean;
|
|
68
|
-
/**
|
|
69
|
-
* One canonical string for a scope set.
|
|
70
|
-
*
|
|
71
|
-
* `["a","b"]` and `["b","a"]` are the same grant, and without this they are two cache keys
|
|
72
|
-
* and two mints for it. Sorted and deduped so the key is a property of the grant rather
|
|
73
|
-
* than of how someone happened to write it down.
|
|
74
|
-
*/
|
|
75
|
-
export declare function normaliseScopes(scopes: readonly string[]): string;
|
|
76
|
-
/** The exact form body of the `client_credentials` grant. */
|
|
77
|
-
export declare function mintForm(credentials: Credentials, scope: string, expiresIn?: number): Record<string, string>;
|
|
78
|
-
/**
|
|
79
|
-
* The token endpoint, through `apiClient` per rules/security.md — no bare `fetch`.
|
|
80
|
-
*
|
|
81
|
-
* `raise_error: false` keeps the status-first contract this transport has always had: the
|
|
82
|
-
* caller distinguishes a 400 scope refusal from a 401 rejection from an unreachable host,
|
|
83
|
-
* so a thrown AxiosError on any non-2xx would destroy the only signal it reads.
|
|
84
|
-
*
|
|
85
|
-
* `timeoutMs` IS RESOLVED INSIDE THE CLOSURE, NOT AS A DEFAULT PARAMETER, and that is load
|
|
86
|
-
* bearing.
|
|
87
|
-
*
|
|
88
|
-
* This module sits inside an import cycle — `lib/apiClient` -> `lib/utils` -> `src/index`,
|
|
89
|
-
* the entry — so its top-level bindings are evaluated AFTER the entry starts running. Tool
|
|
90
|
-
* registration calls `fetchTokenTransport()` during that window: the function itself is
|
|
91
|
-
* hoisted and callable, but a default parameter reading `TOKEN_TIMEOUT_MS` is still in its
|
|
92
|
-
* temporal dead zone and threw `Cannot access 'TOKEN_TIMEOUT_MS' before initialization` on
|
|
93
|
-
* every server start. Reading it when a token is actually minted defers it past init.
|
|
94
|
-
*
|
|
95
|
-
* Anything else added here must follow the same rule until that cycle is broken.
|
|
96
|
-
*/
|
|
97
|
-
export declare function fetchTokenTransport(timeoutMs?: number): TokenTransport;
|
|
98
|
-
/** Drop every cached token. For tests, and for a credential rotation. */
|
|
99
|
-
export declare function resetTokenCache(): void;
|
|
100
|
-
export interface MintOptions {
|
|
101
|
-
url: string;
|
|
102
|
-
/** Already normalised — `normaliseScopes` at the edge, not here. */
|
|
103
|
-
scope: string;
|
|
104
|
-
credentials: Credentials;
|
|
105
|
-
transport: TokenTransport;
|
|
106
|
-
/**
|
|
107
|
-
* Treat a token as stale this long before it actually expires.
|
|
108
|
-
*
|
|
109
|
-
* It must cover everything the token has to outlive, not merely clock skew: a caller that
|
|
110
|
-
* holds it for the length of a run needs a skew at least that long, or a human approves a
|
|
111
|
-
* write and the egress that follows fails on an expired credential.
|
|
112
|
-
*/
|
|
113
|
-
refreshSkewMs: number;
|
|
114
|
-
/**
|
|
115
|
-
* False in a multi-tenant process. These are per-user attested credentials and the hosted
|
|
116
|
-
* process is shared by every tenant — `rules/multi-tenant-safety.md` forbids holding user
|
|
117
|
-
* data in module-level state there, so hosted mode mints per call. The cache key already
|
|
118
|
-
* means one user can never be SERVED another's token, but containment is not the
|
|
119
|
-
* contract; not holding it at all is.
|
|
120
|
-
*/
|
|
121
|
-
cacheEnabled: boolean;
|
|
122
|
-
onMinted?: (lifetimeMs: number) => void;
|
|
123
|
-
now?: number;
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Return a valid token, minting one only when the cache has nothing fresh.
|
|
127
|
-
*
|
|
128
|
-
* Minting per call would add a round trip to every request and make the token endpoint a hot
|
|
129
|
-
* dependency of the whole surface.
|
|
130
|
-
*/
|
|
131
|
-
export declare function mintCentralToken(options: MintOptions): Promise<string>;
|
|
132
|
-
/** Drop the cached token for exactly these credentials — used after an upstream 401. */
|
|
133
|
-
export declare function invalidateToken(url: string, scope: string, credentials: Credentials): void;
|
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mint a BrowserStack central-OAuth JWT from the caller's username and access key.
|
|
3
|
-
*
|
|
4
|
-
* WHAT IS MINTED IS THE CALLER, NOT A SERVICE ACCOUNT. The exchange takes the user's own
|
|
5
|
-
* credentials and returns a JWT whose `user` claim carries their `user_id`/`group_id`, so a
|
|
6
|
-
* product authorises against signed claims rather than against anything we put in a request
|
|
7
|
-
* body. Scopes narrow it further. The property this preserves is the one the whole surface
|
|
8
|
-
* rests on: an agent can never do what the user could not.
|
|
9
|
-
*
|
|
10
|
-
* SECRET HYGIENE IS THE WHOLE POINT OF THIS MODULE, and Atlas's `central_oauth.py` learned
|
|
11
|
-
* it the hard way: "The body can echo the credential back on some errors, so it is NOT
|
|
12
|
-
* logged or raised — only the status." Neither the access key nor the minted token is ever
|
|
13
|
-
* logged, returned in an error, or put in a message. Only a status code is.
|
|
14
|
-
*
|
|
15
|
-
* THIS MODULE IS THE MECHANISM AND NOTHING ELSE. It throws `CentralAuthError` carrying a
|
|
16
|
-
* `kind`, and each caller words its own message from that: `ask-browserstack` tells the
|
|
17
|
-
* reader nothing reached the agent, the capability registry tells them which product could
|
|
18
|
-
* not be reached. Sharing the mechanism is what stops a second, subtly different minter
|
|
19
|
-
* existing; sharing the prose would put Atlas's wording in front of a registry user.
|
|
20
|
-
*/
|
|
21
|
-
import { createHash } from "node:crypto";
|
|
22
|
-
import { apiClient } from "./apiClient.js";
|
|
23
|
-
/** What we ask for. The endpoint clamps to its own maximum, so the response wins. */
|
|
24
|
-
export const REQUESTED_EXPIRES_IN = 3600;
|
|
25
|
-
/**
|
|
26
|
-
* The token endpoint gets its own, short budget — it is not the call being made.
|
|
27
|
-
*
|
|
28
|
-
* READ AT CALL TIME, NEVER IN A DEFAULT PARAMETER — see `fetchTokenTransport`.
|
|
29
|
-
*/
|
|
30
|
-
export const TOKEN_TIMEOUT_MS = 15_000;
|
|
31
|
-
/** Carries the classification and the status. NEVER any part of the response body. */
|
|
32
|
-
export class CentralAuthError extends Error {
|
|
33
|
-
kind;
|
|
34
|
-
status;
|
|
35
|
-
scope;
|
|
36
|
-
constructor(kind, status, scope) {
|
|
37
|
-
super(`central auth ${kind} (HTTP ${status})`);
|
|
38
|
-
this.kind = kind;
|
|
39
|
-
this.status = status;
|
|
40
|
-
this.scope = scope;
|
|
41
|
-
this.name = "CentralAuthError";
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* The OAuth2 error codes we are willing to read out of a failure body.
|
|
46
|
-
*
|
|
47
|
-
* `error` is a fixed enum token in the spec, so it cannot carry a credential;
|
|
48
|
-
* `error_description` is free text and demonstrably CAN ("access_key <key> is invalid"),
|
|
49
|
-
* which is why only the code is ever looked at and only when it is one of these. Anything
|
|
50
|
-
* else is ignored entirely and the classification falls back to the status.
|
|
51
|
-
*/
|
|
52
|
-
const SCOPE_ERROR_CODES = [
|
|
53
|
-
"invalid_scope",
|
|
54
|
-
"unauthorized_client",
|
|
55
|
-
"invalid_request",
|
|
56
|
-
];
|
|
57
|
-
const CREDENTIAL_ERROR_CODES = [
|
|
58
|
-
"invalid_client",
|
|
59
|
-
"invalid_grant",
|
|
60
|
-
"access_denied",
|
|
61
|
-
];
|
|
62
|
-
/**
|
|
63
|
-
* Was this refusal about the SCOPE or about the CREDENTIAL?
|
|
64
|
-
*
|
|
65
|
-
* The two need completely different fixes — provisioning versus a password — so collapsing
|
|
66
|
-
* them into one message sends someone to the wrong place entirely. Our form has five fields
|
|
67
|
-
* and four of them are constants, so a refusal of the REQUEST (as opposed to the caller) can
|
|
68
|
-
* only really be about the scope.
|
|
69
|
-
*
|
|
70
|
-
* Nothing from the body is ever surfaced; the code is used to classify and then discarded.
|
|
71
|
-
*/
|
|
72
|
-
export function refusalIsAboutScope(status, body) {
|
|
73
|
-
const payload = typeof body === "object" && body !== null
|
|
74
|
-
? body
|
|
75
|
-
: {};
|
|
76
|
-
const code = typeof payload.error === "string" ? payload.error : "";
|
|
77
|
-
if (SCOPE_ERROR_CODES.includes(code))
|
|
78
|
-
return true;
|
|
79
|
-
if (CREDENTIAL_ERROR_CODES.includes(code))
|
|
80
|
-
return false;
|
|
81
|
-
// No usable code. OAuth2 answers a bad REQUEST with 400 and a bad CLIENT with 401/403, so
|
|
82
|
-
// the status is the next best evidence.
|
|
83
|
-
return status === 400;
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* One canonical string for a scope set.
|
|
87
|
-
*
|
|
88
|
-
* `["a","b"]` and `["b","a"]` are the same grant, and without this they are two cache keys
|
|
89
|
-
* and two mints for it. Sorted and deduped so the key is a property of the grant rather
|
|
90
|
-
* than of how someone happened to write it down.
|
|
91
|
-
*/
|
|
92
|
-
export function normaliseScopes(scopes) {
|
|
93
|
-
return [
|
|
94
|
-
...new Set(scopes.flatMap((scope) => scope.split(/\s+/)).filter(Boolean)),
|
|
95
|
-
]
|
|
96
|
-
.sort()
|
|
97
|
-
.join(" ");
|
|
98
|
-
}
|
|
99
|
-
/** The exact form body of the `client_credentials` grant. */
|
|
100
|
-
export function mintForm(credentials, scope, expiresIn = REQUESTED_EXPIRES_IN) {
|
|
101
|
-
return {
|
|
102
|
-
grant_type: "client_credentials",
|
|
103
|
-
username: credentials.username,
|
|
104
|
-
access_key: credentials.accessKey,
|
|
105
|
-
scope,
|
|
106
|
-
expires_in: String(expiresIn),
|
|
107
|
-
};
|
|
108
|
-
}
|
|
109
|
-
/**
|
|
110
|
-
* The token endpoint, through `apiClient` per rules/security.md — no bare `fetch`.
|
|
111
|
-
*
|
|
112
|
-
* `raise_error: false` keeps the status-first contract this transport has always had: the
|
|
113
|
-
* caller distinguishes a 400 scope refusal from a 401 rejection from an unreachable host,
|
|
114
|
-
* so a thrown AxiosError on any non-2xx would destroy the only signal it reads.
|
|
115
|
-
*
|
|
116
|
-
* `timeoutMs` IS RESOLVED INSIDE THE CLOSURE, NOT AS A DEFAULT PARAMETER, and that is load
|
|
117
|
-
* bearing.
|
|
118
|
-
*
|
|
119
|
-
* This module sits inside an import cycle — `lib/apiClient` -> `lib/utils` -> `src/index`,
|
|
120
|
-
* the entry — so its top-level bindings are evaluated AFTER the entry starts running. Tool
|
|
121
|
-
* registration calls `fetchTokenTransport()` during that window: the function itself is
|
|
122
|
-
* hoisted and callable, but a default parameter reading `TOKEN_TIMEOUT_MS` is still in its
|
|
123
|
-
* temporal dead zone and threw `Cannot access 'TOKEN_TIMEOUT_MS' before initialization` on
|
|
124
|
-
* every server start. Reading it when a token is actually minted defers it past init.
|
|
125
|
-
*
|
|
126
|
-
* Anything else added here must follow the same rule until that cycle is broken.
|
|
127
|
-
*/
|
|
128
|
-
export function fetchTokenTransport(timeoutMs) {
|
|
129
|
-
return async (url, form) => {
|
|
130
|
-
try {
|
|
131
|
-
const response = await apiClient.post({
|
|
132
|
-
url,
|
|
133
|
-
headers: {
|
|
134
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
135
|
-
Accept: "application/json",
|
|
136
|
-
},
|
|
137
|
-
body: new URLSearchParams(form).toString(),
|
|
138
|
-
timeout: timeoutMs ?? TOKEN_TIMEOUT_MS,
|
|
139
|
-
raise_error: false,
|
|
140
|
-
});
|
|
141
|
-
return { status: response.status, body: response.data ?? null };
|
|
142
|
-
}
|
|
143
|
-
catch {
|
|
144
|
-
// DNS, TLS, timeout — all of them mean "no token". The reason is deliberately not
|
|
145
|
-
// carried: it can name the URL and, on some stacks, echo the request body.
|
|
146
|
-
return { status: 0, body: null, error: "auth could not be reached" };
|
|
147
|
-
}
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
const cache = new Map();
|
|
151
|
-
/** Drop every cached token. For tests, and for a credential rotation. */
|
|
152
|
-
export function resetTokenCache() {
|
|
153
|
-
cache.clear();
|
|
154
|
-
}
|
|
155
|
-
/**
|
|
156
|
-
* The cache key.
|
|
157
|
-
*
|
|
158
|
-
* Every part of it is something that changes WHICH token is correct:
|
|
159
|
-
*
|
|
160
|
-
* url a different issuer is a different token
|
|
161
|
-
* scope a token minted for one scope set must never be served for another
|
|
162
|
-
* username the identity the JWT attests
|
|
163
|
-
* digest so ROTATING the access key mints immediately rather than leaving a revoked
|
|
164
|
-
* credential working until expiry — hashed, never the value, so the secret is
|
|
165
|
-
* not left sitting in a map key for the life of the process
|
|
166
|
-
*
|
|
167
|
-
* The PRODUCT is deliberately absent: the token is the caller's identity, not a product's,
|
|
168
|
-
* so two products on the same endpoint and scopes share one mint.
|
|
169
|
-
*/
|
|
170
|
-
function cacheKey(url, scope, credentials) {
|
|
171
|
-
const digest = createHash("sha256")
|
|
172
|
-
.update(credentials.accessKey)
|
|
173
|
-
.digest("hex");
|
|
174
|
-
return `${url} ${credentials.username} ${scope} ${digest}`;
|
|
175
|
-
}
|
|
176
|
-
async function mintOnce(url, scope, credentials, transport) {
|
|
177
|
-
const response = await transport(url, mintForm(credentials, scope));
|
|
178
|
-
if (response.status === 0)
|
|
179
|
-
throw new CentralAuthError("unreachable", 0, scope);
|
|
180
|
-
// 5xx BEFORE the refusal branch: a server error is not a refusal, and reading it as one
|
|
181
|
-
// is worse than saying nothing — it names the caller's credentials as the fault.
|
|
182
|
-
if (response.status >= 500)
|
|
183
|
-
throw new CentralAuthError("server_error", response.status, scope);
|
|
184
|
-
if (response.status !== 200) {
|
|
185
|
-
// ONLY THE STATUS CROSSES. The body is read solely to tell a provisioning problem from a
|
|
186
|
-
// credential one, and nothing out of it is ever put in the error.
|
|
187
|
-
throw new CentralAuthError(refusalIsAboutScope(response.status, response.body)
|
|
188
|
-
? "refused_scope"
|
|
189
|
-
: "refused_credentials", response.status, scope);
|
|
190
|
-
}
|
|
191
|
-
const body = typeof response.body === "object" && response.body !== null
|
|
192
|
-
? response.body
|
|
193
|
-
: {};
|
|
194
|
-
const token = body.access_token;
|
|
195
|
-
if (typeof token !== "string" || !token)
|
|
196
|
-
throw new CentralAuthError("unusable", response.status, scope);
|
|
197
|
-
// Trust the SERVER's lifetime over what we asked for — it clamps to its own maximum, and
|
|
198
|
-
// caching for the requested hour when it granted less would hand out a dead token.
|
|
199
|
-
const granted = Number(body.expires_in);
|
|
200
|
-
const seconds = Number.isFinite(granted) && granted > 0 ? granted : REQUESTED_EXPIRES_IN;
|
|
201
|
-
return { token, lifetimeMs: seconds * 1000 };
|
|
202
|
-
}
|
|
203
|
-
/**
|
|
204
|
-
* Return a valid token, minting one only when the cache has nothing fresh.
|
|
205
|
-
*
|
|
206
|
-
* Minting per call would add a round trip to every request and make the token endpoint a hot
|
|
207
|
-
* dependency of the whole surface.
|
|
208
|
-
*/
|
|
209
|
-
export async function mintCentralToken(options) {
|
|
210
|
-
const { url, scope, credentials, transport, cacheEnabled } = options;
|
|
211
|
-
const now = options.now ?? Date.now();
|
|
212
|
-
// Refused before any network call, and by name: these ARE the auth credential now, not
|
|
213
|
-
// merely attribution, so an empty one is our missing configuration rather than the user's
|
|
214
|
-
// rejected password, and must not read like one.
|
|
215
|
-
if (!credentials?.username || !credentials?.accessKey)
|
|
216
|
-
throw new CentralAuthError("no_credentials", 0, scope);
|
|
217
|
-
const finish = ({ token, lifetimeMs, }) => {
|
|
218
|
-
options.onMinted?.(lifetimeMs);
|
|
219
|
-
return token;
|
|
220
|
-
};
|
|
221
|
-
if (!cacheEnabled)
|
|
222
|
-
return mintOnce(url, scope, credentials, transport).then(finish);
|
|
223
|
-
const key = cacheKey(url, scope, credentials);
|
|
224
|
-
const entry = cache.get(key);
|
|
225
|
-
if (entry && entry.token && now < entry.expiresAt - options.refreshSkewMs)
|
|
226
|
-
return entry.token;
|
|
227
|
-
// Double-checked through a shared promise: concurrent callers await the same mint.
|
|
228
|
-
if (entry?.inflight)
|
|
229
|
-
return entry.inflight;
|
|
230
|
-
const pending = mintOnce(url, scope, credentials, transport)
|
|
231
|
-
.then(({ token, lifetimeMs }) => {
|
|
232
|
-
cache.set(key, { token, expiresAt: now + lifetimeMs });
|
|
233
|
-
return finish({ token, lifetimeMs });
|
|
234
|
-
})
|
|
235
|
-
.catch((error) => {
|
|
236
|
-
// Never leave a rejected promise cached, or every later call inherits this failure.
|
|
237
|
-
cache.delete(key);
|
|
238
|
-
throw error;
|
|
239
|
-
});
|
|
240
|
-
cache.set(key, { token: "", expiresAt: 0, inflight: pending });
|
|
241
|
-
return pending;
|
|
242
|
-
}
|
|
243
|
-
/** Drop the cached token for exactly these credentials — used after an upstream 401. */
|
|
244
|
-
export function invalidateToken(url, scope, credentials) {
|
|
245
|
-
cache.delete(cacheKey(url, scope, credentials));
|
|
246
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bearer-JWT auth for products whose API does not accept `Api-Token`.
|
|
3
|
-
*
|
|
4
|
-
* The registry was built on "the caller's credentials, forwarded — never a token this server
|
|
5
|
-
* mints", and this is the first product that cannot work that way. The security property is
|
|
6
|
-
* unchanged: the exchange uses the caller's own username and access key, the JWT carries
|
|
7
|
-
* their `user_id`/`group_id` as signed claims, and the scopes narrow it BELOW what the raw
|
|
8
|
-
* credential could do. What changes is that a token is now minted rather than formatted, so
|
|
9
|
-
* the sentence to hold onto is "the server grants nothing beyond the caller's own identity",
|
|
10
|
-
* not "the server mints nothing".
|
|
11
|
-
*
|
|
12
|
-
* The mint itself lives in `lib/central-oauth.ts` and is shared with `askBrowserStackAI`.
|
|
13
|
-
* This module is only the part that is the registry's: where the endpoint and scopes come
|
|
14
|
-
* from, what a product is allowed to ask for, and how a failure is worded.
|
|
15
|
-
*/
|
|
16
|
-
import { Credentials, TokenTransport } from "../../lib/central-oauth.js";
|
|
17
|
-
import { AuthScheme } from "./types.js";
|
|
18
|
-
/**
|
|
19
|
-
* Where a central-OAuth JWT is minted, when a product does not name its own.
|
|
20
|
-
*
|
|
21
|
-
* Staging, for anyone setting the override:
|
|
22
|
-
* CAPABILITY_REGISTRY_AUTH_TOKEN_URL = https://auth-preprod.bsstag.com/oauth2/v2/token
|
|
23
|
-
*/
|
|
24
|
-
export declare const DEFAULT_AUTH_TOKEN_URL = "https://auth.browserstack.com/oauth2/v2/token";
|
|
25
|
-
/**
|
|
26
|
-
* The default scope set, and why it is a PAIR.
|
|
27
|
-
*
|
|
28
|
-
* `ai_agent` is the scope a product's gate matches on. `oauth_user_profile` is not extra
|
|
29
|
-
* authority — it is what makes the scope obtainable through the username+access_key flow at
|
|
30
|
-
* all, and this is measured rather than assumed: `ai_agent` alone comes back
|
|
31
|
-
* `400 invalid_request`, the pair mints, and the granted token's `scopes` claim carries both.
|
|
32
|
-
*
|
|
33
|
-
* Deliberately NOT `ask-browserstack`'s `CENTRAL_SCOPE`, which pairs `ai_agent_notify`
|
|
34
|
-
* instead — Atlas names its own so that changing this one cannot silently change what Atlas
|
|
35
|
-
* is authorised for.
|
|
36
|
-
*/
|
|
37
|
-
export declare const DEFAULT_SCOPES: readonly ["ai_agent", "oauth_user_profile"];
|
|
38
|
-
/**
|
|
39
|
-
* Small, because the registry uses the token for ONE HTTP call.
|
|
40
|
-
*
|
|
41
|
-
* Nothing here holds it for the length of a run the way Atlas does, so this only has to
|
|
42
|
-
* cover clock skew and the flight time of the request it opens.
|
|
43
|
-
*/
|
|
44
|
-
export declare const REFRESH_SKEW_MS = 60000;
|
|
45
|
-
/** For tests, and for anything that legitimately re-resolves. */
|
|
46
|
-
export declare function resetOAuthAnnouncements(): void;
|
|
47
|
-
/**
|
|
48
|
-
* Three rungs: the operator's override, then the product's declaration, then the built-in.
|
|
49
|
-
*
|
|
50
|
-
* Matching `base_url`, where config is explicitly "a default, not the last word" — a
|
|
51
|
-
* deployment pointed at staging must not be overruled by whatever an index happens to carry.
|
|
52
|
-
*/
|
|
53
|
-
export declare function resolveTokenUrl(product: string, auth?: AuthScheme): string;
|
|
54
|
-
/** Two rungs. A scope does not vary by deployment the way a host does, so there is no env one. */
|
|
55
|
-
export declare function resolveScope(auth?: AuthScheme): string;
|
|
56
|
-
/**
|
|
57
|
-
* The Bearer value for one invocation.
|
|
58
|
-
*
|
|
59
|
-
* Cached per user for the life of the token when this process serves one caller, and not at
|
|
60
|
-
* all when it serves many — `mintCentralToken` is handed `cacheEnabled` rather than deciding
|
|
61
|
-
* for itself, because the rule is about THIS deployment, not about the mint.
|
|
62
|
-
*/
|
|
63
|
-
export declare function bearerToken(product: string, auth: AuthScheme | undefined, credentials: Credentials, transport: TokenTransport): Promise<string>;
|
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bearer-JWT auth for products whose API does not accept `Api-Token`.
|
|
3
|
-
*
|
|
4
|
-
* The registry was built on "the caller's credentials, forwarded — never a token this server
|
|
5
|
-
* mints", and this is the first product that cannot work that way. The security property is
|
|
6
|
-
* unchanged: the exchange uses the caller's own username and access key, the JWT carries
|
|
7
|
-
* their `user_id`/`group_id` as signed claims, and the scopes narrow it BELOW what the raw
|
|
8
|
-
* credential could do. What changes is that a token is now minted rather than formatted, so
|
|
9
|
-
* the sentence to hold onto is "the server grants nothing beyond the caller's own identity",
|
|
10
|
-
* not "the server mints nothing".
|
|
11
|
-
*
|
|
12
|
-
* The mint itself lives in `lib/central-oauth.ts` and is shared with `askBrowserStackAI`.
|
|
13
|
-
* This module is only the part that is the registry's: where the endpoint and scopes come
|
|
14
|
-
* from, what a product is allowed to ask for, and how a failure is worded.
|
|
15
|
-
*/
|
|
16
|
-
import appConfig from "../../config.js";
|
|
17
|
-
import logger from "../../logger.js";
|
|
18
|
-
import { CentralAuthError, mintCentralToken, normaliseScopes, } from "../../lib/central-oauth.js";
|
|
19
|
-
import { InvocationError } from "./index-loader.js";
|
|
20
|
-
/**
|
|
21
|
-
* Where a central-OAuth JWT is minted, when a product does not name its own.
|
|
22
|
-
*
|
|
23
|
-
* Staging, for anyone setting the override:
|
|
24
|
-
* CAPABILITY_REGISTRY_AUTH_TOKEN_URL = https://auth-preprod.bsstag.com/oauth2/v2/token
|
|
25
|
-
*/
|
|
26
|
-
export const DEFAULT_AUTH_TOKEN_URL = "https://auth.browserstack.com/oauth2/v2/token";
|
|
27
|
-
/**
|
|
28
|
-
* The default scope set, and why it is a PAIR.
|
|
29
|
-
*
|
|
30
|
-
* `ai_agent` is the scope a product's gate matches on. `oauth_user_profile` is not extra
|
|
31
|
-
* authority — it is what makes the scope obtainable through the username+access_key flow at
|
|
32
|
-
* all, and this is measured rather than assumed: `ai_agent` alone comes back
|
|
33
|
-
* `400 invalid_request`, the pair mints, and the granted token's `scopes` claim carries both.
|
|
34
|
-
*
|
|
35
|
-
* Deliberately NOT `ask-browserstack`'s `CENTRAL_SCOPE`, which pairs `ai_agent_notify`
|
|
36
|
-
* instead — Atlas names its own so that changing this one cannot silently change what Atlas
|
|
37
|
-
* is authorised for.
|
|
38
|
-
*/
|
|
39
|
-
export const DEFAULT_SCOPES = ["ai_agent", "oauth_user_profile"];
|
|
40
|
-
/**
|
|
41
|
-
* Small, because the registry uses the token for ONE HTTP call.
|
|
42
|
-
*
|
|
43
|
-
* Nothing here holds it for the length of a run the way Atlas does, so this only has to
|
|
44
|
-
* cover clock skew and the flight time of the request it opens.
|
|
45
|
-
*/
|
|
46
|
-
export const REFRESH_SKEW_MS = 60_000;
|
|
47
|
-
/**
|
|
48
|
-
* Hosts a PRODUCT INDEX may name as its token endpoint.
|
|
49
|
-
*
|
|
50
|
-
* The index is a data file that ships to public npm and is read at startup. Letting it name
|
|
51
|
-
* an arbitrary `token_url` would let a file decide where the caller's username and access key
|
|
52
|
-
* get POSTed — so the file declares intent and this list bounds it. An operator setting the
|
|
53
|
-
* environment variable is a deliberate act and is trusted; shipping an index is not.
|
|
54
|
-
*/
|
|
55
|
-
const ALLOWED_TOKEN_HOSTS = [
|
|
56
|
-
"auth.browserstack.com",
|
|
57
|
-
"auth-preprod.bsstag.com",
|
|
58
|
-
];
|
|
59
|
-
/** Announced once per distinct resolution, so a wrong endpoint is a log line, not a deduction. */
|
|
60
|
-
const announced = new Set();
|
|
61
|
-
/** For tests, and for anything that legitimately re-resolves. */
|
|
62
|
-
export function resetOAuthAnnouncements() {
|
|
63
|
-
announced.clear();
|
|
64
|
-
}
|
|
65
|
-
function announce(product, url, source) {
|
|
66
|
-
const line = `${product}|${url}|${source}`;
|
|
67
|
-
if (announced.has(line))
|
|
68
|
-
return;
|
|
69
|
-
announced.add(line);
|
|
70
|
-
logger.info("capability registry: %s mints its token at %s (source: %s)", product, url, source);
|
|
71
|
-
}
|
|
72
|
-
function assertMintable(url, product) {
|
|
73
|
-
let parsed;
|
|
74
|
-
try {
|
|
75
|
-
parsed = new URL(url);
|
|
76
|
-
}
|
|
77
|
-
catch {
|
|
78
|
-
throw new InvocationError(`product '${product}' declares a token_url that is not a URL: ${JSON.stringify(url)}`);
|
|
79
|
-
}
|
|
80
|
-
// Same rule as the product call itself: credentials are never attached over plaintext.
|
|
81
|
-
if (parsed.protocol !== "https:") {
|
|
82
|
-
throw new InvocationError(`product '${product}' declares a token_url that is not https. The caller's ` +
|
|
83
|
-
`credentials are sent to it, so plaintext is refused.`);
|
|
84
|
-
}
|
|
85
|
-
if (!ALLOWED_TOKEN_HOSTS.includes(parsed.hostname)) {
|
|
86
|
-
throw new InvocationError(`product '${product}' declares a token_url on an unrecognised host ` +
|
|
87
|
-
`'${parsed.hostname}'. An index may only mint against ${ALLOWED_TOKEN_HOSTS.join(" or ")}; ` +
|
|
88
|
-
`set CAPABILITY_REGISTRY_AUTH_TOKEN_URL if this deployment really does mint elsewhere.`);
|
|
89
|
-
}
|
|
90
|
-
return url.replace(/\/+$/, "");
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Three rungs: the operator's override, then the product's declaration, then the built-in.
|
|
94
|
-
*
|
|
95
|
-
* Matching `base_url`, where config is explicitly "a default, not the last word" — a
|
|
96
|
-
* deployment pointed at staging must not be overruled by whatever an index happens to carry.
|
|
97
|
-
*/
|
|
98
|
-
export function resolveTokenUrl(product, auth) {
|
|
99
|
-
const explicit = process.env.CAPABILITY_REGISTRY_AUTH_TOKEN_URL;
|
|
100
|
-
if (explicit && explicit.trim()) {
|
|
101
|
-
const url = explicit.trim().replace(/\/+$/, "");
|
|
102
|
-
announce(product, url, "env");
|
|
103
|
-
return url;
|
|
104
|
-
}
|
|
105
|
-
if (auth?.token_url && auth.token_url.trim()) {
|
|
106
|
-
const url = assertMintable(auth.token_url.trim(), product);
|
|
107
|
-
announce(product, url, "index");
|
|
108
|
-
return url;
|
|
109
|
-
}
|
|
110
|
-
announce(product, DEFAULT_AUTH_TOKEN_URL, "default");
|
|
111
|
-
return DEFAULT_AUTH_TOKEN_URL;
|
|
112
|
-
}
|
|
113
|
-
/** Two rungs. A scope does not vary by deployment the way a host does, so there is no env one. */
|
|
114
|
-
export function resolveScope(auth) {
|
|
115
|
-
const declared = auth?.scopes;
|
|
116
|
-
if (Array.isArray(declared) && declared.length > 0)
|
|
117
|
-
return normaliseScopes(declared);
|
|
118
|
-
return normaliseScopes([...DEFAULT_SCOPES]);
|
|
119
|
-
}
|
|
120
|
-
/**
|
|
121
|
-
* A failure to mint is not a failure of the product, and must not read like one.
|
|
122
|
-
*
|
|
123
|
-
* Nothing from the token endpoint's body reaches these messages — it can echo the access key
|
|
124
|
-
* straight back. Only the classification and the status do.
|
|
125
|
-
*/
|
|
126
|
-
function toInvocationError(error, product) {
|
|
127
|
-
if (!(error instanceof CentralAuthError))
|
|
128
|
-
return error;
|
|
129
|
-
const prefix = `token_mint_failed: could not sign in to '${product}'`;
|
|
130
|
-
switch (error.kind) {
|
|
131
|
-
case "no_credentials":
|
|
132
|
-
return new InvocationError(`${prefix}: BrowserStack username and access key are required`);
|
|
133
|
-
case "unreachable":
|
|
134
|
-
return new InvocationError(`${prefix}: BrowserStack auth could not be reached. This is a connectivity or ` +
|
|
135
|
-
`auth-server problem, not a problem with your credentials. Nothing was sent to ` +
|
|
136
|
-
`'${product}'.`);
|
|
137
|
-
case "server_error":
|
|
138
|
-
return new InvocationError(`${prefix}: BrowserStack auth is unavailable (HTTP ${error.status}). YOUR ` +
|
|
139
|
-
`CREDENTIALS ARE NOT THE PROBLEM — a 5xx is the auth service failing, not a ` +
|
|
140
|
-
`rejection. Nothing was sent to '${product}'.`);
|
|
141
|
-
case "refused_scope":
|
|
142
|
-
return new InvocationError(`${prefix}: BrowserStack auth would not issue a token for the scope ` +
|
|
143
|
-
`"${error.scope}" (HTTP ${error.status}). YOUR CREDENTIALS ARE NOT THE PROBLEM — ` +
|
|
144
|
-
`this is a provisioning problem and the scope has to be enabled for this account ` +
|
|
145
|
-
`or application. This server will NOT retry with a weaker scope. Nothing was ` +
|
|
146
|
-
`sent to '${product}'.`);
|
|
147
|
-
case "refused_credentials":
|
|
148
|
-
return new InvocationError(`${prefix}: your BrowserStack credentials were rejected by BrowserStack auth ` +
|
|
149
|
-
`(HTTP ${error.status}). Nothing was sent to '${product}'.`);
|
|
150
|
-
default:
|
|
151
|
-
return new InvocationError(`${prefix}: BrowserStack auth answered HTTP ${error.status} without issuing a ` +
|
|
152
|
-
`token. Nothing was sent to '${product}'.`);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
/**
|
|
156
|
-
* The Bearer value for one invocation.
|
|
157
|
-
*
|
|
158
|
-
* Cached per user for the life of the token when this process serves one caller, and not at
|
|
159
|
-
* all when it serves many — `mintCentralToken` is handed `cacheEnabled` rather than deciding
|
|
160
|
-
* for itself, because the rule is about THIS deployment, not about the mint.
|
|
161
|
-
*/
|
|
162
|
-
export async function bearerToken(product, auth, credentials, transport) {
|
|
163
|
-
try {
|
|
164
|
-
return await mintCentralToken({
|
|
165
|
-
url: resolveTokenUrl(product, auth),
|
|
166
|
-
scope: resolveScope(auth),
|
|
167
|
-
credentials,
|
|
168
|
-
transport,
|
|
169
|
-
refreshSkewMs: REFRESH_SKEW_MS,
|
|
170
|
-
cacheEnabled: !appConfig.REMOTE_MCP,
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
catch (error) {
|
|
174
|
-
throw toInvocationError(error, product);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
/** What a routing call concluded about the user's request. */
|
|
2
|
-
export interface RoutingVerdict {
|
|
3
|
-
/** `clash`: several products claim it. `blank`: no product's words at all. `settled`: one. */
|
|
4
|
-
verdict: "clash" | "blank" | "settled";
|
|
5
|
-
/** The product it settled on, for `settled`. */
|
|
6
|
-
product?: string;
|
|
7
|
-
/** The shared words, for `clash` — what the user has to be asked about. */
|
|
8
|
-
terms?: string[];
|
|
9
|
-
/** Issued-at, epoch seconds. */
|
|
10
|
-
iat: number;
|
|
11
|
-
}
|
|
12
|
-
/** Tokens outlive a turn, not a sitting. Long enough to answer a question, not to bank. */
|
|
13
|
-
export declare const TOKEN_TTL_SECONDS: number;
|
|
14
|
-
/** Sign a verdict into an opaque token the caller hands back. */
|
|
15
|
-
export declare function signRouting(verdict: Omit<RoutingVerdict, "iat"> & {
|
|
16
|
-
iat?: number;
|
|
17
|
-
}): string;
|
|
18
|
-
/**
|
|
19
|
-
* Read a token back, or `undefined` if it was not issued here, was edited, or has expired.
|
|
20
|
-
*
|
|
21
|
-
* Every failure returns the same nothing on purpose: a caller learning WHY its token was
|
|
22
|
-
* rejected learns how to build a better one, and there is no legitimate caller that needs
|
|
23
|
-
* to know the difference.
|
|
24
|
-
*/
|
|
25
|
-
export declare function readRouting(token: string | undefined, now?: number): RoutingVerdict | undefined;
|