@typeship-ax/cli 0.22.0 → 0.23.1
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/AGENTS.md +12 -8
- package/README.md +14 -27
- package/api.json +8898 -9026
- package/api.md +369 -327
- package/dist/arguments.d.ts +47 -0
- package/dist/arguments.d.ts.map +1 -0
- package/dist/arguments.js +254 -0
- package/dist/cli-agent.d.ts +31 -8
- package/dist/cli-agent.d.ts.map +1 -1
- package/dist/cli-agent.js +146 -28
- package/dist/cli.js +483 -237
- package/dist/core/http.d.ts +162 -19
- package/dist/core/http.d.ts.map +1 -1
- package/dist/core/http.js +381 -48
- package/dist/core/pagination.d.ts +42 -6
- package/dist/core/pagination.d.ts.map +1 -1
- package/dist/core/pagination.js +111 -17
- package/dist/credential-storage.d.ts +10 -3
- package/dist/credential-storage.d.ts.map +1 -1
- package/dist/credential-storage.js +15 -6
- package/dist/dates.d.ts +1 -1
- package/dist/dates.js +1 -1
- package/dist/errors.d.ts +20 -84
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +20 -108
- package/dist/fields.d.ts +29 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/fields.js +101 -0
- package/dist/index.d.ts +28 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -25
- package/dist/named-credentials.d.ts +19 -0
- package/dist/named-credentials.d.ts.map +1 -1
- package/dist/named-credentials.js +81 -1
- package/dist/oauth-login.d.ts +8 -2
- package/dist/oauth-login.d.ts.map +1 -1
- package/dist/oauth-login.js +31 -19
- package/dist/oauth-request.d.ts +7 -1
- package/dist/oauth-request.d.ts.map +1 -1
- package/dist/oauth-request.js +26 -4
- package/dist/oauth-session.d.ts +13 -1
- package/dist/oauth-session.d.ts.map +1 -1
- package/dist/oauth-session.js +34 -18
- package/dist/ops.d.ts +53 -5
- package/dist/ops.d.ts.map +1 -1
- package/dist/ops.js +49 -40
- package/dist/polling-login.d.ts +8 -2
- package/dist/polling-login.d.ts.map +1 -1
- package/dist/polling-login.js +25 -11
- package/dist/resources/api-keys.d.ts +10 -7
- package/dist/resources/api-keys.d.ts.map +1 -1
- package/dist/resources/api-keys.js +10 -31
- package/dist/resources/deliveries.d.ts +88 -4
- package/dist/resources/deliveries.d.ts.map +1 -1
- package/dist/resources/deliveries.js +95 -18
- package/dist/resources/drafts.d.ts +15 -15
- package/dist/resources/drafts.d.ts.map +1 -1
- package/dist/resources/drafts.js +11 -64
- package/dist/resources/files.d.ts +4 -4
- package/dist/resources/files.d.ts.map +1 -1
- package/dist/resources/files.js +3 -12
- package/dist/resources/generations.d.ts +14 -14
- package/dist/resources/generations.d.ts.map +1 -1
- package/dist/resources/generations.js +21 -45
- package/dist/resources/organization.d.ts +4 -4
- package/dist/resources/organization.d.ts.map +1 -1
- package/dist/resources/organization.js +3 -10
- package/dist/resources/{generate.d.ts → packages.d.ts} +16 -16
- package/dist/resources/packages.d.ts.map +1 -0
- package/dist/resources/{generate.js → packages.js} +13 -29
- package/dist/resources/projects.d.ts +50 -50
- package/dist/resources/projects.d.ts.map +1 -1
- package/dist/resources/projects.js +60 -116
- package/dist/resources/releases.d.ts +21 -16
- package/dist/resources/releases.d.ts.map +1 -1
- package/dist/resources/releases.js +18 -39
- package/dist/resources/spec-revisions.d.ts +15 -6
- package/dist/resources/spec-revisions.d.ts.map +1 -1
- package/dist/resources/spec-revisions.js +6 -28
- package/dist/resources/specs.d.ts +7 -7
- package/dist/resources/specs.d.ts.map +1 -1
- package/dist/resources/specs.js +6 -34
- package/dist/resources/targets.d.ts +48 -48
- package/dist/resources/targets.d.ts.map +1 -1
- package/dist/resources/targets.js +58 -114
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +83 -81
- package/dist/search.d.ts +54 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +421 -0
- package/dist/types.d.ts +499 -339
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +18 -18
- package/package.json +5 -2
- package/src/arguments.ts +242 -0
- package/src/cli-agent.ts +156 -30
- package/src/cli.ts +444 -211
- package/src/core/http.ts +457 -58
- package/src/core/pagination.ts +129 -18
- package/src/credential-storage.ts +16 -6
- package/src/dates.ts +1 -1
- package/src/errors.ts +46 -115
- package/src/fields.ts +91 -0
- package/src/index.ts +45 -28
- package/src/named-credentials.ts +66 -1
- package/src/oauth-login.ts +36 -21
- package/src/oauth-request.ts +32 -6
- package/src/oauth-session.ts +37 -19
- package/src/ops.ts +82 -44
- package/src/polling-login.ts +24 -11
- package/src/resources/api-keys.ts +34 -48
- package/src/resources/deliveries.ts +211 -30
- package/src/resources/drafts.ts +60 -107
- package/src/resources/files.ts +19 -20
- package/src/resources/generations.ts +57 -75
- package/src/resources/organization.ts +11 -16
- package/src/resources/{generate.ts → packages.ts} +43 -51
- package/src/resources/projects.ts +145 -200
- package/src/resources/releases.ts +48 -65
- package/src/resources/spec-revisions.ts +38 -47
- package/src/resources/specs.ts +39 -59
- package/src/resources/targets.ts +143 -193
- package/src/schemas.ts +83 -81
- package/src/search.ts +434 -0
- package/src/types.ts +538 -357
- package/dist/console-login-check.d.ts +0 -21
- package/dist/console-login-check.d.ts.map +0 -1
- package/dist/console-login-check.js +0 -107
- package/dist/console-login-contract.d.ts +0 -45
- package/dist/console-login-contract.d.ts.map +0 -1
- package/dist/console-login-contract.js +0 -40
- package/dist/resources/generate.d.ts.map +0 -1
- package/dist/resources/publications.d.ts +0 -47
- package/dist/resources/publications.d.ts.map +0 -1
- package/dist/resources/publications.js +0 -70
- package/src/console-login-check.ts +0 -88
- package/src/console-login-contract.ts +0 -65
- package/src/resources/publications.ts +0 -140
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
// Generated by
|
|
1
|
+
// Typeship — client entry point.
|
|
2
|
+
// Generated by Typeship — https://typeship.dev
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
5
|
HttpCore,
|
|
@@ -10,34 +10,33 @@ import {
|
|
|
10
10
|
type RequestContext,
|
|
11
11
|
bearerAuth,
|
|
12
12
|
} from "./core/http.js";
|
|
13
|
-
import { DEFS, SCHEMAS } from "./schemas.js";
|
|
14
13
|
|
|
15
|
-
import { GenerateResource } from "./resources/generate.js";
|
|
16
14
|
import { ProjectsResource } from "./resources/projects.js";
|
|
17
15
|
import { SpecsResource } from "./resources/specs.js";
|
|
18
16
|
import { SpecRevisionsResource } from "./resources/spec-revisions.js";
|
|
19
17
|
import { TargetsResource } from "./resources/targets.js";
|
|
20
|
-
import { DraftsResource } from "./resources/drafts.js";
|
|
21
|
-
import { ReleasesResource } from "./resources/releases.js";
|
|
22
18
|
import { DeliveriesResource } from "./resources/deliveries.js";
|
|
23
|
-
import { PublicationsResource } from "./resources/publications.js";
|
|
24
19
|
import { GenerationsResource } from "./resources/generations.js";
|
|
20
|
+
import { DraftsResource } from "./resources/drafts.js";
|
|
21
|
+
import { ReleasesResource } from "./resources/releases.js";
|
|
25
22
|
import { FilesResource } from "./resources/files.js";
|
|
23
|
+
import { PackagesResource } from "./resources/packages.js";
|
|
26
24
|
import { OrganizationResource } from "./resources/organization.js";
|
|
27
25
|
import { ApiKeysResource } from "./resources/api-keys.js";
|
|
28
26
|
|
|
29
27
|
/** This package's version, also sent as the `User-Agent`. */
|
|
30
|
-
export const VERSION = "0.
|
|
31
|
-
const USER_AGENT = "@typeship-ax/cli/0.
|
|
28
|
+
export const VERSION = "0.23.1";
|
|
29
|
+
const USER_AGENT = "@typeship-ax/cli/0.23.1";
|
|
32
30
|
|
|
33
31
|
export interface ClientOptions {
|
|
34
32
|
/** Override the server URL. Default: `https://typeship.dev/api/v1` */
|
|
35
33
|
baseUrl?: string;
|
|
36
34
|
/**
|
|
37
35
|
* Sent as `Authorization: Bearer <token>`. Pass a callback for tokens that expire; it is resolved
|
|
38
|
-
* before every attempt
|
|
36
|
+
* before every attempt, and after a 401 it is called once more with `{ rejected: true }` so it
|
|
37
|
+
* can refresh instead of returning the rejected token.
|
|
39
38
|
*/
|
|
40
|
-
bearerToken?:
|
|
39
|
+
bearerToken?: AuthValue;
|
|
41
40
|
/**
|
|
42
41
|
* Credentials keyed by the Spec's security scheme names. Only one complete alternative is sent
|
|
43
42
|
* for each operation. Named values take precedence over convenience options.
|
|
@@ -45,10 +44,15 @@ export interface ClientOptions {
|
|
|
45
44
|
credentials?: {
|
|
46
45
|
"apiKey"?: AuthValue;
|
|
47
46
|
};
|
|
48
|
-
/** Per-attempt timeout in milliseconds. Default:
|
|
47
|
+
/** Per-attempt timeout in milliseconds. Default: 60000. */
|
|
49
48
|
timeoutMs?: number;
|
|
50
49
|
/** Retries after the first attempt (retryable failures only). Default: 2. */
|
|
51
50
|
maxRetries?: number;
|
|
51
|
+
/**
|
|
52
|
+
* The longest wait a server may request (Retry-After, x-ratelimit-reset) that a retry honors; a
|
|
53
|
+
* longer one fails the call with the reset time. Default: 60000.
|
|
54
|
+
*/
|
|
55
|
+
maxRetryWaitMs?: number;
|
|
52
56
|
/** Custom fetch implementation (proxies, testing, instrumentation). */
|
|
53
57
|
fetch?: typeof fetch;
|
|
54
58
|
/** Headers sent with every request. */
|
|
@@ -83,7 +87,7 @@ export interface ClientOptions {
|
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
/**
|
|
86
|
-
*
|
|
90
|
+
* Typeship — v0.23.1
|
|
87
91
|
*
|
|
88
92
|
* Resolve an OpenAPI or GraphQL Spec, diagnose it, and keep every
|
|
89
93
|
* selected CLI, MCP, and SDK Target current.
|
|
@@ -100,21 +104,22 @@ export interface ClientOptions {
|
|
|
100
104
|
* petstore Spec is a runnable sample.
|
|
101
105
|
*/
|
|
102
106
|
export class TypeshipClient {
|
|
103
|
-
readonly generate: GenerateResource;
|
|
104
107
|
readonly projects: ProjectsResource;
|
|
105
108
|
readonly specs: SpecsResource;
|
|
106
109
|
readonly specRevisions: SpecRevisionsResource;
|
|
107
110
|
readonly targets: TargetsResource;
|
|
108
|
-
readonly drafts: DraftsResource;
|
|
109
|
-
readonly releases: ReleasesResource;
|
|
110
111
|
readonly deliveries: DeliveriesResource;
|
|
111
|
-
readonly publications: PublicationsResource;
|
|
112
112
|
readonly generations: GenerationsResource;
|
|
113
|
+
readonly drafts: DraftsResource;
|
|
114
|
+
readonly releases: ReleasesResource;
|
|
113
115
|
readonly files: FilesResource;
|
|
116
|
+
readonly packages: PackagesResource;
|
|
114
117
|
readonly organization: OrganizationResource;
|
|
115
118
|
readonly apiKeys: ApiKeysResource;
|
|
119
|
+
private readonly _options: ClientOptions;
|
|
116
120
|
|
|
117
121
|
constructor(options: ClientOptions = {}) {
|
|
122
|
+
this._options = options;
|
|
118
123
|
// Identifies this package to the API (ignored by browsers, which
|
|
119
124
|
// control their own User-Agent); override via defaultHeaders.
|
|
120
125
|
const headers: Record<string, AuthValue> = { "User-Agent": USER_AGENT, ...options.defaultHeaders };
|
|
@@ -157,30 +162,42 @@ export class TypeshipClient {
|
|
|
157
162
|
query,
|
|
158
163
|
credentials,
|
|
159
164
|
fetch: options.fetch ?? fetch,
|
|
160
|
-
timeoutMs: options.timeoutMs ??
|
|
165
|
+
timeoutMs: options.timeoutMs ?? 60_000,
|
|
161
166
|
maxRetries: options.maxRetries ?? 2,
|
|
167
|
+
maxRetryWaitMs: options.maxRetryWaitMs,
|
|
162
168
|
onRequest: options.onRequest,
|
|
163
169
|
onResponse: options.onResponse,
|
|
164
170
|
onError: options.onError,
|
|
165
171
|
debug,
|
|
166
172
|
validate,
|
|
167
|
-
|
|
168
|
-
|
|
173
|
+
// Loaded on the first validated request, so the schema table costs
|
|
174
|
+
// nothing when validation is off.
|
|
175
|
+
loadSchemas: validate ? () => import("./schemas.js") : undefined,
|
|
169
176
|
});
|
|
170
|
-
this.generate = new GenerateResource(core);
|
|
171
177
|
this.projects = new ProjectsResource(core);
|
|
172
178
|
this.specs = new SpecsResource(core);
|
|
173
179
|
this.specRevisions = new SpecRevisionsResource(core);
|
|
174
180
|
this.targets = new TargetsResource(core);
|
|
175
|
-
this.drafts = new DraftsResource(core);
|
|
176
|
-
this.releases = new ReleasesResource(core);
|
|
177
181
|
this.deliveries = new DeliveriesResource(core);
|
|
178
|
-
this.publications = new PublicationsResource(core);
|
|
179
182
|
this.generations = new GenerationsResource(core);
|
|
183
|
+
this.drafts = new DraftsResource(core);
|
|
184
|
+
this.releases = new ReleasesResource(core);
|
|
180
185
|
this.files = new FilesResource(core);
|
|
186
|
+
this.packages = new PackagesResource(core);
|
|
181
187
|
this.organization = new OrganizationResource(core);
|
|
182
188
|
this.apiKeys = new ApiKeysResource(core);
|
|
183
189
|
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A client with this client's configuration and only these credentials,
|
|
193
|
+
* for a server acting on behalf of many users. Nothing is inherited from
|
|
194
|
+
* this client's credentials. The copy shares the configured fetch.
|
|
195
|
+
*/
|
|
196
|
+
withCredentials(credentials: Pick<ClientOptions, "bearerToken" | "credentials">): TypeshipClient {
|
|
197
|
+
const options: Record<string, unknown> = { ...this._options };
|
|
198
|
+
for (const key of ["bearerToken","credentials"]) delete options[key];
|
|
199
|
+
return new TypeshipClient({ ...options, ...credentials } as ClientOptions);
|
|
200
|
+
}
|
|
184
201
|
}
|
|
185
202
|
|
|
186
203
|
export * from "./types.js";
|
|
@@ -188,6 +205,7 @@ export * from "./errors.js";
|
|
|
188
205
|
export {
|
|
189
206
|
formatDebugEvent,
|
|
190
207
|
type AuthValue,
|
|
208
|
+
type CredentialContext,
|
|
191
209
|
type DebugEvent,
|
|
192
210
|
type RequestContext,
|
|
193
211
|
type RequestOptions,
|
|
@@ -195,16 +213,15 @@ export {
|
|
|
195
213
|
} from "./core/http.js";
|
|
196
214
|
export { Page, PagePromise } from "./core/pagination.js";
|
|
197
215
|
|
|
198
|
-
export * from "./resources/generate.js";
|
|
199
216
|
export * from "./resources/projects.js";
|
|
200
217
|
export * from "./resources/specs.js";
|
|
201
218
|
export * from "./resources/spec-revisions.js";
|
|
202
219
|
export * from "./resources/targets.js";
|
|
203
|
-
export * from "./resources/drafts.js";
|
|
204
|
-
export * from "./resources/releases.js";
|
|
205
220
|
export * from "./resources/deliveries.js";
|
|
206
|
-
export * from "./resources/publications.js";
|
|
207
221
|
export * from "./resources/generations.js";
|
|
222
|
+
export * from "./resources/drafts.js";
|
|
223
|
+
export * from "./resources/releases.js";
|
|
208
224
|
export * from "./resources/files.js";
|
|
225
|
+
export * from "./resources/packages.js";
|
|
209
226
|
export * from "./resources/organization.js";
|
|
210
227
|
export * from "./resources/api-keys.js";
|
package/src/named-credentials.ts
CHANGED
|
@@ -34,7 +34,7 @@ export function resolveNamedCredentials(schemes: CredentialSchemes, layers: { na
|
|
|
34
34
|
for (const layer of layers) {
|
|
35
35
|
for (const [name, scheme] of Object.entries(schemes)) {
|
|
36
36
|
for (const option of scheme.options) {
|
|
37
|
-
if (option === "clientCredentials") continue;
|
|
37
|
+
if (option === "clientCredentials" || option === "refreshToken") continue;
|
|
38
38
|
const value = layer.options?.[option];
|
|
39
39
|
if (value !== undefined) resolved[name] = value as NamedCredential;
|
|
40
40
|
}
|
|
@@ -52,6 +52,28 @@ export function namedCredentialAvailability(schemes: CredentialSchemes, options:
|
|
|
52
52
|
return options;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** The schemes an interactive OAuth login session authenticates by name. When
|
|
56
|
+
* the API also declares a separate non-OAuth bearer scheme, the convenience
|
|
57
|
+
* bearer token belongs to that scheme, so the session must be passed to each
|
|
58
|
+
* OAuth scheme by name. Empty: the session is the convenience bearer token. */
|
|
59
|
+
export function oauthSessionSchemes(schemes: CredentialSchemes): string[] {
|
|
60
|
+
return Object.entries(schemes).filter(([, scheme]) => (scheme.options.includes("clientCredentials") || scheme.options.includes("refreshToken")) && !scheme.options.includes("bearerToken")).map(([name]) => name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Whether resolved client options satisfy one complete credential alternative
|
|
64
|
+
* of an operation. Returns null when satisfied (or when no credential is
|
|
65
|
+
* required); otherwise the named alternatives and the schemes each lacks. */
|
|
66
|
+
export function missingCredentials(schemes: CredentialSchemes, credentialOptions: string[][] | undefined, options: Record<string, unknown>): { alternatives: string[][]; missing: string[][] } | null {
|
|
67
|
+
const supplied = (key: string, value: unknown): boolean => typeof value === "function" || (typeof value === "string" && value.length > 0)
|
|
68
|
+
|| (key === "clientCredentials" && value !== null && typeof value === "object")
|
|
69
|
+
|| (value !== null && typeof value === "object" && "username" in value && "password" in value && typeof value.username === "string" && value.username.length > 0 && typeof value.password === "string" && value.password.length > 0);
|
|
70
|
+
const named = Object.fromEntries(Object.entries((options.credentials ?? {}) as Record<string, unknown>).filter(([name, value]) => supplied(name, value))) as NamedCredentials;
|
|
71
|
+
const available = namedCredentialAvailability(schemes, new Set(Object.keys(options).filter((key) => key !== "credentials" && supplied(key, options[key]))), named);
|
|
72
|
+
if (credentialOptions?.some((alternative) => alternative.length > 0 && alternative.every((option) => available.has(option)))) return null;
|
|
73
|
+
const alternatives = (credentialOptions ?? []).filter((alternative) => alternative.length > 0 && alternative.every((option) => option.startsWith("credentials."))).map((alternative) => alternative.map((option) => option.slice("credentials.".length)));
|
|
74
|
+
return { alternatives, missing: alternatives.map((alternative) => alternative.filter((name) => !available.has("credentials." + name))) };
|
|
75
|
+
}
|
|
76
|
+
|
|
55
77
|
/** Bound file/stdin reads before allocating a full credential document. */
|
|
56
78
|
export function readNamedCredentialsFile(input: string, schemes: CredentialSchemes): NamedCredentials {
|
|
57
79
|
let fd: number | undefined;
|
|
@@ -72,3 +94,46 @@ export function readNamedCredentialsFile(input: string, schemes: CredentialSchem
|
|
|
72
94
|
} finally { if (fd !== undefined && input !== "-") closeSync(fd); }
|
|
73
95
|
return parseNamedCredentials(Buffer.concat(chunks).toString("utf8"), schemes);
|
|
74
96
|
}
|
|
97
|
+
|
|
98
|
+
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
99
|
+
const TRANSPORT_HEADERS = new Set(["host", "content-length", "content-type", "transfer-encoding", "connection"]);
|
|
100
|
+
|
|
101
|
+
/** Extra request headers from repeated `--header "Name: value"` flags and a
|
|
102
|
+
* `<PREFIX>_HEADERS` variable (a JSON object, or one `Name: value` per line).
|
|
103
|
+
* The escape hatch for credentials a spec does not declare. Flags win over
|
|
104
|
+
* the environment; values never appear in errors. */
|
|
105
|
+
export function parseExtraHeaders(environment: string | undefined, flags: readonly string[], variable: string): Record<string, string> {
|
|
106
|
+
const entries: [string, string][] = [];
|
|
107
|
+
const line = (text: string, source: string) => {
|
|
108
|
+
const colon = text.indexOf(":");
|
|
109
|
+
if (colon <= 0) throw new Error(source + " expects \"Name: value\".");
|
|
110
|
+
entries.push([text.slice(0, colon).trim(), text.slice(colon + 1).trim()]);
|
|
111
|
+
};
|
|
112
|
+
if (environment !== undefined && environment.trim()) {
|
|
113
|
+
const text = environment.trim();
|
|
114
|
+
if (text.startsWith("{")) {
|
|
115
|
+
let value: unknown;
|
|
116
|
+
try { value = JSON.parse(text); } catch { throw new Error(variable + " must be a JSON object of header names to values, or one \"Name: value\" per line."); }
|
|
117
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.values(value).some((entry) => typeof entry !== "string")) throw new Error(variable + " must map header names to string values.");
|
|
118
|
+
entries.push(...Object.entries(value as Record<string, string>));
|
|
119
|
+
} else for (const part of text.split(/\r?\n/)) if (part.trim()) line(part, variable);
|
|
120
|
+
}
|
|
121
|
+
for (const flag of flags) line(flag, "--header");
|
|
122
|
+
const headers: Record<string, string> = {};
|
|
123
|
+
for (const [name, value] of entries) {
|
|
124
|
+
if (!HEADER_NAME.test(name)) throw new Error("Header names may contain only token characters. Check --header and " + variable + ".");
|
|
125
|
+
if (TRANSPORT_HEADERS.has(name.toLowerCase())) throw new Error("The " + name + " header is set by the client and cannot be overridden.");
|
|
126
|
+
if (/[\r\n\0]/.test(value)) throw new Error("Header values cannot contain line breaks. Check --header and " + variable + ".");
|
|
127
|
+
for (const existing of Object.keys(headers)) if (existing.toLowerCase() === name.toLowerCase()) delete headers[existing];
|
|
128
|
+
headers[name] = value;
|
|
129
|
+
}
|
|
130
|
+
return headers;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Apply extra headers last, replacing any header of the same name. */
|
|
134
|
+
export function applyExtraHeaders(target: Record<string, string>, extra: Record<string, string>): void {
|
|
135
|
+
for (const [name, value] of Object.entries(extra)) {
|
|
136
|
+
for (const existing of Object.keys(target)) if (existing.toLowerCase() === name.toLowerCase()) delete target[existing];
|
|
137
|
+
target[name] = value;
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/oauth-login.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
|
-
import { oauthJsonRequest, OAuthResponseError } from "./oauth-request.js";
|
|
3
|
+
import { oauthJsonRequest, OAuthResponseError, providerErrorOf } from "./oauth-request.js";
|
|
4
|
+
|
|
5
|
+
/** Browser OAuth login did not complete; the message names the cause. */
|
|
6
|
+
export class OAuthLoginError extends Error {
|
|
7
|
+
constructor(message?: string) { super(message); this.name = "OAuthLoginError"; }
|
|
8
|
+
}
|
|
4
9
|
|
|
5
10
|
export interface OAuthLoginConfig {
|
|
6
|
-
issuer
|
|
11
|
+
/** Exact issuer, when the provider publishes metadata. Without one, the
|
|
12
|
+
* explicit authorization and token URLs from the API Spec are used. */
|
|
13
|
+
issuer?: string | null;
|
|
7
14
|
clientId: string;
|
|
8
15
|
discoveryUrl?: string;
|
|
9
16
|
authorizationUrl?: string;
|
|
@@ -21,7 +28,7 @@ export interface OAuthLoginSession {
|
|
|
21
28
|
accessToken: string;
|
|
22
29
|
refreshToken?: string;
|
|
23
30
|
expiresAt?: number;
|
|
24
|
-
issuer
|
|
31
|
+
issuer?: string;
|
|
25
32
|
clientId: string;
|
|
26
33
|
tokenUrl: string;
|
|
27
34
|
/** The actual native callback used for this exchange, including its port. */
|
|
@@ -41,14 +48,19 @@ function endpoint(value: string): URL {
|
|
|
41
48
|
const url = new URL(value);
|
|
42
49
|
const loopback = ["127.0.0.1", "[::1]", "localhost"].includes(url.hostname);
|
|
43
50
|
if ((url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) || url.username || url.password || url.hash) {
|
|
44
|
-
throw new
|
|
51
|
+
throw new OAuthLoginError("OAuth endpoints require HTTPS without credentials or fragments; loopback HTTP is allowed for development.");
|
|
45
52
|
}
|
|
46
53
|
return url;
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
async function metadata(config: OAuthLoginConfig, signal: AbortSignal) {
|
|
57
|
+
// The Spec's authorizationCode flow names both endpoints; no discovery.
|
|
58
|
+
if (!config.issuer) {
|
|
59
|
+
if (config.authorizationUrl && config.tokenUrl) return { authorizationUrl: endpoint(config.authorizationUrl), tokenUrl: endpoint(config.tokenUrl).href };
|
|
60
|
+
throw new OAuthLoginError("Browser login needs the provider's authorization and token URLs, or an issuer that publishes them.");
|
|
61
|
+
}
|
|
50
62
|
const issuer = endpoint(config.issuer);
|
|
51
|
-
if (issuer.search) throw new
|
|
63
|
+
if (issuer.search) throw new OAuthLoginError("The OAuth issuer must not contain a query.");
|
|
52
64
|
const path = issuer.pathname.replace(/\/$/, "");
|
|
53
65
|
const urls = config.discoveryUrl ? [config.discoveryUrl] : [
|
|
54
66
|
issuer.origin + "/.well-known/oauth-authorization-server" + path,
|
|
@@ -63,13 +75,13 @@ async function metadata(config: OAuthLoginConfig, signal: AbortSignal) {
|
|
|
63
75
|
}
|
|
64
76
|
if (response.status !== 200) continue;
|
|
65
77
|
const data = response.data!;
|
|
66
|
-
if (data.issuer !== config.issuer) throw new
|
|
78
|
+
if (data.issuer !== config.issuer) throw new OAuthLoginError("OAuth discovery returned a different issuer. Check the issuer and discovery URL.");
|
|
67
79
|
if (Array.isArray(data.code_challenge_methods_supported) && !data.code_challenge_methods_supported.includes("S256")) {
|
|
68
|
-
throw new
|
|
80
|
+
throw new OAuthLoginError("The provider does not advertise S256 PKCE support.");
|
|
69
81
|
}
|
|
70
82
|
const authorizationUrl = config.authorizationUrl ?? data.authorization_endpoint;
|
|
71
83
|
const tokenUrl = config.tokenUrl ?? data.token_endpoint;
|
|
72
|
-
if (typeof authorizationUrl !== "string" || typeof tokenUrl !== "string") throw new
|
|
84
|
+
if (typeof authorizationUrl !== "string" || typeof tokenUrl !== "string") throw new OAuthLoginError("Browser login requires authorization and token endpoints.");
|
|
73
85
|
return {
|
|
74
86
|
authorizationUrl: endpoint(authorizationUrl), tokenUrl: endpoint(tokenUrl).href,
|
|
75
87
|
...(typeof data.revocation_endpoint === "string" ? { revocationUrl: endpoint(data.revocation_endpoint).href } : {}),
|
|
@@ -80,20 +92,20 @@ async function metadata(config: OAuthLoginConfig, signal: AbortSignal) {
|
|
|
80
92
|
if (config.authorizationUrl && config.tokenUrl) return {
|
|
81
93
|
authorizationUrl: endpoint(config.authorizationUrl), tokenUrl: endpoint(config.tokenUrl).href,
|
|
82
94
|
};
|
|
83
|
-
throw new
|
|
95
|
+
throw new OAuthLoginError("Could not discover OAuth endpoints. Check the issuer or provide explicit authorization and token URLs.");
|
|
84
96
|
}
|
|
85
97
|
|
|
86
98
|
/** Authorization Code + S256 PKCE for a public native client. The listener
|
|
87
99
|
* binds only a loopback IP, verifies state/issuer, and closes on every path. */
|
|
88
100
|
export async function oauthBrowserLogin(config: OAuthLoginConfig, interaction: OAuthLoginInteraction): Promise<OAuthLoginSession> {
|
|
89
|
-
if (!config.clientId.trim()) throw new
|
|
90
|
-
if (config.organization && (!["organization", "organization_id"].includes(config.organization.parameter) || typeof config.organization.id !== "string" || !config.organization.id || config.organization.id.length > 512 || /\s|[\u0000-\u001F\u007F]/.test(config.organization.id))) throw new
|
|
101
|
+
if (!config.clientId.trim()) throw new OAuthLoginError("Browser login requires a public client ID.");
|
|
102
|
+
if (config.organization && (!["organization", "organization_id"].includes(config.organization.parameter) || typeof config.organization.id !== "string" || !config.organization.id || config.organization.id.length > 512 || /\s|[\u0000-\u001F\u007F]/.test(config.organization.id))) throw new OAuthLoginError("Organization selection requires a supported parameter and a nonempty organization ID without whitespace.");
|
|
91
103
|
const signal = AbortSignal.any([interaction.signal ?? new AbortController().signal, AbortSignal.timeout(interaction.timeoutMs ?? 600_000)]);
|
|
92
104
|
signal.throwIfAborted();
|
|
93
105
|
const endpoints = await metadata(config, signal);
|
|
94
106
|
const redirect = new URL(config.redirectUri ?? "http://127.0.0.1/callback");
|
|
95
107
|
if (redirect.protocol !== "http:" || !["127.0.0.1", "[::1]"].includes(redirect.hostname) || redirect.username || redirect.password || redirect.search || redirect.hash) {
|
|
96
|
-
throw new
|
|
108
|
+
throw new OAuthLoginError("The OAuth redirect must be an HTTP loopback IP URL without credentials, query, or fragment.");
|
|
97
109
|
}
|
|
98
110
|
const verifier = randomBytes(32).toString("base64url");
|
|
99
111
|
const state = randomBytes(32).toString("base64url");
|
|
@@ -120,24 +132,27 @@ export async function oauthBrowserLogin(config: OAuthLoginConfig, interaction: O
|
|
|
120
132
|
response.writeHead(400).end("Invalid login state. Return to the terminal and try again."); return;
|
|
121
133
|
}
|
|
122
134
|
const returnedIssuer = incoming.searchParams.get("iss");
|
|
123
|
-
if (returnedIssuer !== null && (incoming.searchParams.getAll("iss").length !== 1 || returnedIssuer !== config.issuer)) {
|
|
135
|
+
if (returnedIssuer !== null && config.issuer && (incoming.searchParams.getAll("iss").length !== 1 || returnedIssuer !== config.issuer)) {
|
|
124
136
|
response.writeHead(400).end("The login issuer did not match."); return;
|
|
125
137
|
}
|
|
126
138
|
accepted = true;
|
|
127
139
|
if (incoming.searchParams.has("error")) {
|
|
128
|
-
|
|
140
|
+
const provider = providerErrorOf({ error: incoming.searchParams.get("error"), error_description: incoming.searchParams.get("error_description") ?? undefined });
|
|
141
|
+
const denied = incoming.searchParams.get("error") === "access_denied";
|
|
142
|
+
response.writeHead(400).end("Login was not approved. Return to the terminal.", () => rejectCode(new OAuthLoginError(
|
|
143
|
+
(provider ? "The provider returned " + provider + ". " : "") + (denied ? "OAuth authorization was denied or cancelled. Run login again to retry." : "Check the client ID, the registered redirect URI and the requested scopes, then run login again."))));
|
|
129
144
|
return;
|
|
130
145
|
}
|
|
131
146
|
const code = incoming.searchParams.get("code");
|
|
132
147
|
if (!code || incoming.searchParams.getAll("code").length !== 1) {
|
|
133
|
-
response.writeHead(400).end("The provider returned no authorization code.", () => rejectCode(new
|
|
148
|
+
response.writeHead(400).end("The provider returned no authorization code.", () => rejectCode(new OAuthLoginError("OAuth callback did not contain one authorization code.")));
|
|
134
149
|
return;
|
|
135
150
|
}
|
|
136
151
|
response.end("Authorization received. Return to the terminal to finish signing in.", () => resolveCode(code));
|
|
137
152
|
});
|
|
138
153
|
server.headersTimeout = 10_000;
|
|
139
154
|
server.requestTimeout = 10_000;
|
|
140
|
-
const aborted = () => rejectCode(new
|
|
155
|
+
const aborted = () => rejectCode(new OAuthLoginError("Browser login timed out or was cancelled. Run login again to retry."));
|
|
141
156
|
signal.addEventListener("abort", aborted, { once: true });
|
|
142
157
|
try {
|
|
143
158
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -145,13 +160,13 @@ export async function oauthBrowserLogin(config: OAuthLoginConfig, interaction: O
|
|
|
145
160
|
server.listen(Number(redirect.port) || 0, redirect.hostname === "[::1]" ? "::1" : "127.0.0.1", () => { server.off("error", reject); resolve(); });
|
|
146
161
|
});
|
|
147
162
|
const address = server.address();
|
|
148
|
-
if (!address || typeof address === "string") throw new
|
|
163
|
+
if (!address || typeof address === "string") throw new OAuthLoginError("Could not start the OAuth callback listener.");
|
|
149
164
|
redirect.port = String(address.port);
|
|
150
165
|
signal.throwIfAborted();
|
|
151
166
|
const authorization = new URL(endpoints.authorizationUrl);
|
|
152
167
|
if (config.organization) {
|
|
153
168
|
const other = config.organization.parameter === "organization" ? "organization_id" : "organization";
|
|
154
|
-
if (authorization.searchParams.has(other)) throw new
|
|
169
|
+
if (authorization.searchParams.has(other)) throw new OAuthLoginError("The authorization URL contains a conflicting organization parameter. Update the provider configuration.");
|
|
155
170
|
authorization.searchParams.set(config.organization.parameter, config.organization.id);
|
|
156
171
|
}
|
|
157
172
|
for (const [key, value] of Object.entries({ response_type: "code", client_id: config.clientId, redirect_uri: redirect.href, state, code_challenge: challenge, code_challenge_method: "S256", ...(config.scopes?.length ? { scope: config.scopes.join(" ") } : {}), ...(config.audience ? { audience: config.audience } : {}), ...(config.resource ? { resource: config.resource } : {}) })) authorization.searchParams.set(key, value);
|
|
@@ -165,14 +180,14 @@ export async function oauthBrowserLogin(config: OAuthLoginConfig, interaction: O
|
|
|
165
180
|
});
|
|
166
181
|
const data = response.data;
|
|
167
182
|
if (response.status !== 200 || !data || typeof data.access_token !== "string" || !data.access_token || typeof data.token_type !== "string" || data.token_type.toLowerCase() !== "bearer") {
|
|
168
|
-
throw new
|
|
183
|
+
throw new OAuthLoginError(`OAuth token exchange failed (HTTP ${response.status}${response.providerError ? ": " + response.providerError : ""}). Check the public client registration and run login again.`);
|
|
169
184
|
}
|
|
170
|
-
if (data.expires_in !== undefined && (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0)) throw new
|
|
185
|
+
if (data.expires_in !== undefined && (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0)) throw new OAuthLoginError("OAuth provider returned an invalid token lifetime.");
|
|
171
186
|
return {
|
|
172
187
|
accessToken: data.access_token,
|
|
173
188
|
...(typeof data.refresh_token === "string" && data.refresh_token ? { refreshToken: data.refresh_token } : {}),
|
|
174
189
|
...(typeof data.expires_in === "number" ? { expiresAt: Date.now() + data.expires_in * 1000 } : {}),
|
|
175
|
-
issuer: config.issuer, clientId: config.clientId, tokenUrl: endpoints.tokenUrl, redirectUri: redirect.href,
|
|
190
|
+
...(config.issuer ? { issuer: config.issuer } : {}), clientId: config.clientId, tokenUrl: endpoints.tokenUrl, redirectUri: redirect.href,
|
|
176
191
|
...(endpoints.revocationUrl ? { revocationUrl: endpoints.revocationUrl } : {}),
|
|
177
192
|
...(typeof data.scope === "string" ? { scopes: data.scope.split(/\s+/).filter(Boolean) } : {}),
|
|
178
193
|
};
|
package/src/oauth-request.ts
CHANGED
|
@@ -16,9 +16,23 @@ export class OAuthResponseError extends Error {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export type DeviceOAuthError = "authorization_pending" | "slow_down" | "access_denied" | "expired_token";
|
|
19
|
-
interface AuthenticationResponse {
|
|
19
|
+
interface AuthenticationResponse {
|
|
20
|
+
status: number; data?: Record<string, unknown>; error?: DeviceOAuthError;
|
|
21
|
+
/** RFC 6749 `error` and `error_description` from a 400 or 401 body, as
|
|
22
|
+
* "code: description". Only those two fields, printable ASCII, bounded. */
|
|
23
|
+
providerError?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The provider's standard error fields, never any other body content. */
|
|
27
|
+
export function providerErrorOf(data: Record<string, unknown>): string | undefined {
|
|
28
|
+
const code = typeof data.error === "string" && /^[\x20-\x21\x23-\x5b\x5d-\x7e]{1,64}$/.test(data.error) ? data.error : undefined;
|
|
29
|
+
if (!code) return undefined;
|
|
30
|
+
const description = typeof data.error_description === "string" ? data.error_description.replace(/[^\x20-\x7e]/g, " ").trim().slice(0, 300) : "";
|
|
31
|
+
return description ? code + ": " + description : code;
|
|
32
|
+
}
|
|
20
33
|
|
|
21
|
-
/**
|
|
34
|
+
/** Transport causes and error bodies beyond the standard OAuth `error` and
|
|
35
|
+
* `error_description` fields may contain credentials and are discarded. */
|
|
22
36
|
export function oauthJsonRequest(url: string | URL, init: RequestInit = {}, timeoutMs = 30_000): Promise<AuthenticationResponse> {
|
|
23
37
|
return boundedRequest(url, init, timeoutMs, "json");
|
|
24
38
|
}
|
|
@@ -39,6 +53,9 @@ async function boundedRequest(url: string | URL, init: RequestInit, timeoutMs: n
|
|
|
39
53
|
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
40
54
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
41
55
|
let interrupted: "timed_out" | "cancelled" | undefined;
|
|
56
|
+
// An error status is already an answer: its body only adds the provider's
|
|
57
|
+
// explanation, so a stalled or malformed error body never becomes a failure.
|
|
58
|
+
let errorStatus: number | undefined;
|
|
42
59
|
let rejectDeadline!: (reason: Error) => void;
|
|
43
60
|
const deadline = new Promise<never>((_, reject) => { rejectDeadline = reject; });
|
|
44
61
|
const stop = (code: "timed_out" | "cancelled") => { interrupted ??= code; rejectDeadline(new OAuthResponseError(interrupted)); controller.abort(); };
|
|
@@ -56,7 +73,8 @@ async function boundedRequest(url: string | URL, init: RequestInit, timeoutMs: n
|
|
|
56
73
|
}), deadline,
|
|
57
74
|
]);
|
|
58
75
|
if (response.redirected) { void response.body?.cancel().catch(() => {}); throw new OAuthResponseError("request_failed"); }
|
|
59
|
-
if (mode === "status" || response.status !== 200 &&
|
|
76
|
+
if (mode === "status" || response.status !== 200 && response.status !== 400 && response.status !== 401) { void response.body?.cancel().catch(() => {}); return { status: response.status }; }
|
|
77
|
+
if (response.status !== 200) errorStatus = response.status;
|
|
60
78
|
if (!response.body) throw new OAuthResponseError("invalid_response");
|
|
61
79
|
reader = response.body.getReader();
|
|
62
80
|
let size = 0, text = "";
|
|
@@ -72,14 +90,22 @@ async function boundedRequest(url: string | URL, init: RequestInit, timeoutMs: n
|
|
|
72
90
|
try { text += decoder.decode(); }
|
|
73
91
|
catch { throw new OAuthResponseError("invalid_response"); }
|
|
74
92
|
let data: unknown;
|
|
75
|
-
try { data = JSON.parse(text); } catch {
|
|
76
|
-
|
|
93
|
+
try { data = JSON.parse(text); } catch {
|
|
94
|
+
if (response.status !== 200) return { status: response.status };
|
|
95
|
+
throw new OAuthResponseError("invalid_response");
|
|
96
|
+
}
|
|
97
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
98
|
+
if (response.status !== 200) return { status: response.status };
|
|
99
|
+
throw new OAuthResponseError("invalid_response");
|
|
100
|
+
}
|
|
77
101
|
if (response.status !== 200) {
|
|
78
102
|
const error = (data as Record<string, unknown>).error;
|
|
79
|
-
|
|
103
|
+
const providerError = providerErrorOf(data as Record<string, unknown>);
|
|
104
|
+
return { status: response.status, ...(providerError ? { providerError } : {}), ...(mode === "device" && ["authorization_pending", "slow_down", "access_denied", "expired_token"].includes(String(error)) && typeof error === "string" ? { error: error as DeviceOAuthError } : {}) };
|
|
80
105
|
}
|
|
81
106
|
return { status: response.status, data: data as Record<string, unknown> };
|
|
82
107
|
} catch (error) {
|
|
108
|
+
if (errorStatus !== undefined && interrupted !== "cancelled") return { status: errorStatus };
|
|
83
109
|
if (interrupted) throw new OAuthResponseError(interrupted);
|
|
84
110
|
if (error instanceof OAuthResponseError) throw error;
|
|
85
111
|
throw new OAuthResponseError("request_failed");
|
package/src/oauth-session.ts
CHANGED
|
@@ -26,9 +26,9 @@ export interface CredentialDestination { apiBaseUrl: string; environment?: strin
|
|
|
26
26
|
|
|
27
27
|
export function assertCredentialDestination(credentials: StoredCredentials, destination: CredentialDestination): void {
|
|
28
28
|
const saved = credentials.destination;
|
|
29
|
-
if (!saved) throw new
|
|
30
|
-
if (new URL(saved.apiBaseUrl).href !== new URL(destination.apiBaseUrl).href) throw new
|
|
31
|
-
if ((saved.environment ?? null) !== (destination.environment ?? null) || (saved.profile ?? null) !== (destination.profile ?? null)) throw new
|
|
29
|
+
if (!saved) throw new OAuthSessionError("Saved credentials have no API and profile binding. Log in again before using them.");
|
|
30
|
+
if (new URL(saved.apiBaseUrl).href !== new URL(destination.apiBaseUrl).href) throw new OAuthSessionError("The API destination changed. Log in again before using saved credentials.");
|
|
31
|
+
if ((saved.environment ?? null) !== (destination.environment ?? null) || (saved.profile ?? null) !== (destination.profile ?? null)) throw new OAuthSessionError("The credential environment or profile changed. Log in again before using saved credentials.");
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export interface StoredCredentials {
|
|
@@ -67,7 +67,7 @@ export function credentialIdentityBinding(credentials: StoredCredentials, identi
|
|
|
67
67
|
export function assertStoredIdentity(credentials: StoredCredentials, identity?: IdentityConfiguration): void {
|
|
68
68
|
if (!identity) return;
|
|
69
69
|
if (!credentials.identity?.values || credentials.identity.binding !== credentialIdentityBinding(credentials, identity) || Object.keys(identity.fields).some((kind) => !Object.hasOwn(credentials.identity!.values, kind))) {
|
|
70
|
-
throw new
|
|
70
|
+
throw new OAuthSessionError("The saved login has no matching API identity verification. Sign in again to verify the current account and organization.");
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
73
|
|
|
@@ -99,7 +99,14 @@ function alive(pid: number): boolean {
|
|
|
99
99
|
try { process.kill(pid, 0); return true; } catch (error) { return code(error) !== "ESRCH"; }
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
/** The OS credential store or the saved credential file cannot be used. */
|
|
103
|
+
export class CredentialStorageError extends Error {
|
|
104
|
+
constructor(message?: string) { super(message); this.name = "CredentialStorageError"; }
|
|
105
|
+
}
|
|
106
|
+
/** A saved login cannot be used as is; the remedy is to sign in again. */
|
|
107
|
+
export class OAuthSessionError extends Error {
|
|
108
|
+
constructor(message?: string) { super(message); this.name = "OAuthSessionError"; }
|
|
109
|
+
}
|
|
103
110
|
|
|
104
111
|
export interface CredentialCodec {
|
|
105
112
|
readonly name: string;
|
|
@@ -127,7 +134,7 @@ export class FileCredentialStore implements CredentialStore {
|
|
|
127
134
|
} catch (error) {
|
|
128
135
|
if (code(error) === "ENOENT") return null;
|
|
129
136
|
if (error instanceof CredentialStorageError) throw error;
|
|
130
|
-
throw new
|
|
137
|
+
throw new CredentialStorageError("Cannot read saved credentials. Check the credential store before logging in again.");
|
|
131
138
|
}
|
|
132
139
|
}
|
|
133
140
|
|
|
@@ -154,9 +161,9 @@ export class FileCredentialStore implements CredentialStore {
|
|
|
154
161
|
while (!acquired) {
|
|
155
162
|
try { linkSync(claim, lock); acquired = true; }
|
|
156
163
|
catch (error) {
|
|
157
|
-
if (code(error) !== "EEXIST") throw new
|
|
164
|
+
if (code(error) !== "EEXIST") throw new CredentialStorageError("Cannot lock the credential store.");
|
|
158
165
|
this.recoverDeadOwner(lock);
|
|
159
|
-
if (Date.now() >= deadline) throw new
|
|
166
|
+
if (Date.now() >= deadline) throw new CredentialStorageError("The credential store is busy. Wait for the other login or refresh to finish and retry. If its process stopped, inspect " + lock + ".");
|
|
160
167
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
161
168
|
}
|
|
162
169
|
}
|
|
@@ -199,13 +206,13 @@ export class FileCredentialStore implements CredentialStore {
|
|
|
199
206
|
|
|
200
207
|
function validSession(credentials: StoredCredentials | null, config: SessionConfiguration, sessionId: string, allowPending = false): StoredOAuthSession {
|
|
201
208
|
const session = credentials?.oauth;
|
|
202
|
-
if (!session || !session.sessionId || session.sessionId !== sessionId) throw new
|
|
203
|
-
if (session.apiBaseUrl && new URL(session.apiBaseUrl).href !== new URL(config.apiBaseUrl).href) throw new
|
|
204
|
-
if (session.issuer && config.issuer && session.issuer !== config.issuer) throw new
|
|
205
|
-
if (session.configuredClientId !== (config.clientId ?? null)) throw new
|
|
206
|
-
if (!session.binding || session.binding !== sessionBinding(config)) throw new
|
|
207
|
-
if (session.refreshPending && !allowPending) throw new
|
|
208
|
-
if (typeof session.accessToken !== "string" || !session.accessToken) throw new
|
|
209
|
+
if (!session || !session.sessionId || session.sessionId !== sessionId) throw new OAuthSessionError("The saved OAuth session changed or was logged out. Run login again and retry the request.");
|
|
210
|
+
if (session.apiBaseUrl && new URL(session.apiBaseUrl).href !== new URL(config.apiBaseUrl).href) throw new OAuthSessionError("The API destination changed. Log in again.");
|
|
211
|
+
if (session.issuer && config.issuer && session.issuer !== config.issuer) throw new OAuthSessionError("The configured issuer changed. Log in again.");
|
|
212
|
+
if (session.configuredClientId !== (config.clientId ?? null)) throw new OAuthSessionError("The OAuth client changed. Log in again.");
|
|
213
|
+
if (!session.binding || session.binding !== sessionBinding(config)) throw new OAuthSessionError("The API or OAuth configuration changed. Log in again before using saved credentials.");
|
|
214
|
+
if (session.refreshPending && !allowPending) throw new OAuthSessionError("The previous OAuth refresh did not finish safely. Log in again; its refresh token will not be reused.");
|
|
215
|
+
if (typeof session.accessToken !== "string" || !session.accessToken) throw new OAuthSessionError("The saved OAuth session is invalid. Log in again.");
|
|
209
216
|
assertStoredIdentity(credentials!, config.identity);
|
|
210
217
|
return session;
|
|
211
218
|
}
|
|
@@ -213,15 +220,17 @@ function validSession(credentials: StoredCredentials | null, config: SessionConf
|
|
|
213
220
|
/** Re-read on every request attempt; only one process may rotate the token.
|
|
214
221
|
* A failed or interrupted exchange requires login, since the server may have
|
|
215
222
|
* consumed its refresh token even when no response reached this process. */
|
|
216
|
-
export async function oauthSessionToken(store: CredentialStore, config: SessionConfiguration, sessionId: string, tokenParams: Record<string, string> = {}): Promise<string> {
|
|
217
|
-
|
|
223
|
+
export async function oauthSessionToken(store: CredentialStore, config: SessionConfiguration, sessionId: string, tokenParams: Record<string, string> = {}, rejected?: string): Promise<string> {
|
|
224
|
+
// A token the API just rejected (401) is refreshed once even if unexpired;
|
|
225
|
+
// another process may already have replaced it, which is fine.
|
|
226
|
+
const fresh = (session: StoredOAuthSession) => session.accessToken !== rejected && (session.expiresAt === undefined || (Number.isFinite(session.expiresAt) && session.expiresAt > Date.now() + 60_000));
|
|
218
227
|
const first = validSession(store.read(), config, sessionId, true);
|
|
219
228
|
if (!first.refreshPending && fresh(first)) return first.accessToken;
|
|
220
229
|
return store.withLock(async (locked) => {
|
|
221
230
|
const credentials = locked.read();
|
|
222
231
|
const session = validSession(credentials, config, sessionId);
|
|
223
232
|
if (fresh(session)) return session.accessToken;
|
|
224
|
-
if (!session.refreshToken || !session.tokenUrl || !session.clientId) throw new
|
|
233
|
+
if (!session.refreshToken || !session.tokenUrl || !session.clientId) throw new OAuthSessionError(session.accessToken === rejected ? "The API rejected the saved OAuth session and it has no refresh token. Log in again." : "The OAuth session expired and cannot be refreshed. Log in again.");
|
|
225
234
|
const endpoint = new URL(session.tokenUrl);
|
|
226
235
|
if ((endpoint.protocol !== "https:" && !(endpoint.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname))) || endpoint.username || endpoint.password || endpoint.hash) throw new Error("The OAuth token endpoint requires HTTPS; loopback HTTP is allowed for development.");
|
|
227
236
|
locked.write({ ...credentials, oauth: { ...session, refreshPending: true } });
|
|
@@ -252,7 +261,16 @@ export async function oauthSessionToken(store: CredentialStore, config: SessionC
|
|
|
252
261
|
return next.accessToken;
|
|
253
262
|
} catch {
|
|
254
263
|
// Never expose an error body, a refresh token, or fetch's nested cause.
|
|
255
|
-
throw new
|
|
264
|
+
throw new OAuthSessionError("The OAuth session could not be refreshed safely. Log in again.");
|
|
256
265
|
}
|
|
257
266
|
});
|
|
258
267
|
}
|
|
268
|
+
|
|
269
|
+
/** A saved-session token for the request runtime: resolved before every
|
|
270
|
+
* attempt, and after a 401 (invalidate) the next resolve forces one refresh
|
|
271
|
+
* of the token the API rejected. */
|
|
272
|
+
export function sessionCredential(resolve: (rejected?: string) => Promise<string>): (() => Promise<string>) & { invalidate(): void } {
|
|
273
|
+
let last: string | undefined, rejected: string | undefined;
|
|
274
|
+
const token = async () => { const previous = rejected; rejected = undefined; last = await resolve(previous); return last; };
|
|
275
|
+
return Object.assign(token, { invalidate() { rejected = last; } });
|
|
276
|
+
}
|