@gajae-code/ai 0.13.2 → 0.14.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/CHANGELOG.md +61 -2
- package/dist/types/auth-broker/client.d.ts +9 -1
- package/dist/types/auth-broker/redact.d.ts +7 -0
- package/dist/types/auth-broker/remote-store.d.ts +50 -9
- package/dist/types/auth-broker/types.d.ts +14 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +25 -0
- package/dist/types/auth-storage.d.ts +200 -6
- package/dist/types/core.d.ts +1 -0
- package/dist/types/model-cache.d.ts +4 -1
- package/dist/types/model-manager.d.ts +11 -0
- package/dist/types/provider-models/openai-compat.d.ts +5 -0
- package/dist/types/provider-models/special.d.ts +3 -0
- package/dist/types/providers/anthropic.d.ts +31 -0
- package/dist/types/providers/cursor.d.ts +9 -1
- package/dist/types/providers/kiro-codewhisperer.d.ts +8 -0
- package/dist/types/providers/mock.d.ts +8 -0
- package/dist/types/providers/register-builtins.d.ts +1 -0
- package/dist/types/providers/transform-messages.d.ts +18 -0
- package/dist/types/types.d.ts +34 -8
- package/dist/types/usage/grok-cli.d.ts +5 -0
- package/dist/types/usage.d.ts +6 -0
- package/dist/types/utils/discovery/openai-compatible.d.ts +5 -0
- package/dist/types/utils/event-stream.d.ts +4 -2
- package/dist/types/utils/fallback-transport.d.ts +10 -0
- package/dist/types/utils/http-inspector.d.ts +1 -0
- package/dist/types/utils/idle-iterator.d.ts +13 -1
- package/dist/types/utils/json-parse.d.ts +19 -0
- package/dist/types/utils/oauth/callback-server.d.ts +13 -0
- package/dist/types/utils/oauth/kiro.d.ts +71 -0
- package/dist/types/utils/oauth/types.d.ts +1 -1
- package/dist/types/utils/parse-bind.d.ts +8 -5
- package/dist/types/utils/tool-call-healing.d.ts +7 -0
- package/dist/types/utils/tool-choice-capability.d.ts +11 -0
- package/package.json +3 -2
- package/src/auth-broker/client.ts +30 -0
- package/src/auth-broker/redact.ts +15 -0
- package/src/auth-broker/refresher.ts +4 -2
- package/src/auth-broker/remote-store.ts +693 -70
- package/src/auth-broker/server.ts +57 -12
- package/src/auth-broker/types.ts +16 -0
- package/src/auth-broker/wire-schemas.ts +21 -0
- package/src/auth-gateway/server.ts +84 -19
- package/src/auth-storage.ts +985 -41
- package/src/core.ts +1 -0
- package/src/model-cache.ts +23 -4
- package/src/model-manager.ts +70 -11
- package/src/model-thinking.ts +45 -1
- package/src/models.json +9604 -1932
- package/src/openai-completions-compat.ts +2 -1
- package/src/provider-models/descriptors.ts +7 -1
- package/src/provider-models/openai-compat.ts +52 -28
- package/src/provider-models/special.ts +12 -0
- package/src/providers/amazon-bedrock.ts +2 -1
- package/src/providers/anthropic.ts +831 -27
- package/src/providers/cursor.ts +83 -3
- package/src/providers/kiro-codewhisperer.ts +572 -0
- package/src/providers/mock.ts +15 -2
- package/src/providers/ollama.ts +9 -2
- package/src/providers/openai-codex-responses.ts +16 -9
- package/src/providers/openai-completions.ts +6 -1
- package/src/providers/openai-responses-shared.ts +180 -18
- package/src/providers/register-builtins.ts +24 -2
- package/src/providers/transform-messages.ts +64 -1
- package/src/stream.ts +25 -2
- package/src/types.ts +36 -7
- package/src/usage/grok-cli.ts +86 -1
- package/src/usage.ts +7 -0
- package/src/utils/discovery/openai-compatible.ts +89 -4
- package/src/utils/event-stream.ts +11 -2
- package/src/utils/fallback-transport.ts +44 -2
- package/src/utils/http-inspector.ts +1 -0
- package/src/utils/idle-iterator.ts +29 -6
- package/src/utils/json-parse.ts +80 -0
- package/src/utils/oauth/callback-server.ts +31 -1
- package/src/utils/oauth/index.ts +14 -1
- package/src/utils/oauth/kiro.ts +448 -0
- package/src/utils/oauth/synthetic.ts +2 -3
- package/src/utils/oauth/types.ts +1 -0
- package/src/utils/parse-bind.ts +27 -0
- package/src/utils/tool-call-healing.ts +13 -2
- package/src/utils/tool-choice-capability.ts +386 -6
package/src/utils/json-parse.ts
CHANGED
|
@@ -110,6 +110,86 @@ export function repairJson(json: string): string {
|
|
|
110
110
|
return parts.join("");
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* First unnecessary `\uXXXX` escape in a JSON document, or `undefined` when the
|
|
115
|
+
* document contains none.
|
|
116
|
+
*
|
|
117
|
+
* "Unnecessary" means the escape encodes a character JSON can carry literally:
|
|
118
|
+
* any non-ASCII printable character. Control characters (< U+0020) MUST be
|
|
119
|
+
* escaped, and an unpaired surrogate CANNOT be written literally, so neither
|
|
120
|
+
* counts. A `\\uXXXX` sequence is a literal backslash followed by `u` — the
|
|
121
|
+
* intended source syntax when the model is writing code or a nested JSON
|
|
122
|
+
* document — and is skipped, which is why this scans the raw text with the same
|
|
123
|
+
* string/escape state machine as {@link repairJson} instead of using a regex.
|
|
124
|
+
*
|
|
125
|
+
* Models that spell non-ASCII text as hand-written hex instead of literal UTF-8
|
|
126
|
+
* mistype the digits, and every mistyped nibble silently decodes to a different
|
|
127
|
+
* but perfectly valid character (`\uc7a5` vs `\uc7a4`). The resulting arguments
|
|
128
|
+
* parse cleanly and cannot be repaired after the fact, so the escape itself is
|
|
129
|
+
* the only observable evidence that the payload is untrustworthy.
|
|
130
|
+
*/
|
|
131
|
+
export function findUnnecessaryUnicodeEscape(json: string): string | undefined {
|
|
132
|
+
const len = json.length;
|
|
133
|
+
let inString = false;
|
|
134
|
+
let i = 0;
|
|
135
|
+
|
|
136
|
+
const hexAt = (start: number): number | undefined => {
|
|
137
|
+
if (start + 3 >= len) return undefined;
|
|
138
|
+
for (let k = start; k <= start + 3; k++) if (!isHexDigit(json.charCodeAt(k))) return undefined;
|
|
139
|
+
return Number.parseInt(json.slice(start, start + 4), 16);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
while (i < len) {
|
|
143
|
+
if (!inString) {
|
|
144
|
+
const open = json.indexOf('"', i);
|
|
145
|
+
if (open === -1) return undefined;
|
|
146
|
+
inString = true;
|
|
147
|
+
i = open + 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Jump straight to the next quote or backslash. A per-character walk costs
|
|
152
|
+
// ~40ms on a 1MB literal-UTF-8 payload (a large `write`), and every byte in
|
|
153
|
+
// between is by definition uninteresting.
|
|
154
|
+
const nextQuote = json.indexOf('"', i);
|
|
155
|
+
const nextBackslash = json.indexOf("\\", i);
|
|
156
|
+
if (nextQuote === -1 && nextBackslash === -1) return undefined;
|
|
157
|
+
i = nextBackslash === -1 || (nextQuote !== -1 && nextQuote < nextBackslash) ? nextQuote : nextBackslash;
|
|
158
|
+
|
|
159
|
+
if (json.charCodeAt(i) === QUOTE) {
|
|
160
|
+
inString = false;
|
|
161
|
+
i++;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (json.charCodeAt(i + 1) !== U) {
|
|
165
|
+
// Any other escape (including `\\`) consumes its own second character,
|
|
166
|
+
// so a literal `\uXXXX` in the decoded value is never misread as one.
|
|
167
|
+
i += 2;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const first = hexAt(i + 2);
|
|
172
|
+
if (first === undefined) {
|
|
173
|
+
i += 2;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (first >= 0xd800 && first <= 0xdbff) {
|
|
177
|
+
// High surrogate: only a completed pair denotes a real character.
|
|
178
|
+
const low = json.charCodeAt(i + 6) === BACKSLASH && json.charCodeAt(i + 7) === U ? hexAt(i + 8) : undefined;
|
|
179
|
+
if (low !== undefined && low >= 0xdc00 && low <= 0xdfff) {
|
|
180
|
+
return json.slice(i, i + 12);
|
|
181
|
+
}
|
|
182
|
+
i += 6;
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (first >= 0x80 && !(first >= 0xdc00 && first <= 0xdfff)) {
|
|
186
|
+
return json.slice(i, i + 6);
|
|
187
|
+
}
|
|
188
|
+
i += 6;
|
|
189
|
+
}
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
|
|
113
193
|
export function parseJsonWithRepair<T>(json: string): T {
|
|
114
194
|
try {
|
|
115
195
|
return JSON.parse(json) as T;
|
|
@@ -36,6 +36,17 @@ export interface OAuthCallbackFlowOptions {
|
|
|
36
36
|
* `onManualCodeInput` handler on the controller.
|
|
37
37
|
*/
|
|
38
38
|
skipCallbackServer?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Expected authorization-server issuer recorded from validated metadata
|
|
41
|
+
* (RFC 9207 / MCP 2026-07-28). When set, a present `iss` that differs
|
|
42
|
+
* rejects the response before any other parameter is acted on.
|
|
43
|
+
*/
|
|
44
|
+
expectedIssuer?: string;
|
|
45
|
+
/**
|
|
46
|
+
* `authorization_response_iss_parameter_supported` from the same metadata.
|
|
47
|
+
* When true, a response WITHOUT `iss` is rejected.
|
|
48
|
+
*/
|
|
49
|
+
issuerResponseIssSupported?: boolean;
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
/**
|
|
@@ -48,6 +59,8 @@ export abstract class OAuthCallbackFlow {
|
|
|
48
59
|
callbackHostname: string;
|
|
49
60
|
callbackBindHostname: string;
|
|
50
61
|
redirectUri?: string;
|
|
62
|
+
expectedIssuer?: string;
|
|
63
|
+
issuerResponseIssSupported?: boolean;
|
|
51
64
|
readonly #skipCallbackServer: boolean;
|
|
52
65
|
#callbackResolve?: (result: CallbackResult) => void;
|
|
53
66
|
#callbackReject?: (error: string) => void;
|
|
@@ -72,6 +85,8 @@ export abstract class OAuthCallbackFlow {
|
|
|
72
85
|
this.callbackHostname = preferredPortOrOptions.callbackHostname ?? DEFAULT_HOSTNAME;
|
|
73
86
|
this.callbackBindHostname = preferredPortOrOptions.callbackBindHostname ?? this.callbackHostname;
|
|
74
87
|
this.redirectUri = preferredPortOrOptions.redirectUri;
|
|
88
|
+
this.expectedIssuer = preferredPortOrOptions.expectedIssuer;
|
|
89
|
+
this.issuerResponseIssSupported = preferredPortOrOptions.issuerResponseIssSupported;
|
|
75
90
|
this.#skipCallbackServer = preferredPortOrOptions.skipCallbackServer === true;
|
|
76
91
|
}
|
|
77
92
|
|
|
@@ -202,12 +217,27 @@ export abstract class OAuthCallbackFlow {
|
|
|
202
217
|
const state = url.searchParams.get("state") || "";
|
|
203
218
|
const error = url.searchParams.get("error") || "";
|
|
204
219
|
const errorDescription = url.searchParams.get("error_description") || error;
|
|
220
|
+
const iss = url.searchParams.get("iss");
|
|
205
221
|
|
|
206
222
|
type OkState = { ok: true; code: string; state: string };
|
|
207
223
|
type ErrorState = { ok?: false; error?: string };
|
|
208
224
|
let resultState: OkState | ErrorState;
|
|
209
225
|
|
|
210
|
-
|
|
226
|
+
// RFC 9207 §2.4 (MCP 2026-07-28): validate the response issuer before acting
|
|
227
|
+
// on any other parameter; on mismatch, server-supplied error details must not
|
|
228
|
+
// be acted on or displayed, so the failure message is generic by design.
|
|
229
|
+
let issuerFailure: string | null = null;
|
|
230
|
+
if (this.expectedIssuer) {
|
|
231
|
+
if (iss !== null && iss !== this.expectedIssuer) {
|
|
232
|
+
issuerFailure = "Authorization response issuer mismatch";
|
|
233
|
+
} else if (iss === null && this.issuerResponseIssSupported === true) {
|
|
234
|
+
issuerFailure = "Authorization response missing required issuer (iss)";
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (issuerFailure) {
|
|
239
|
+
resultState = { ok: false, error: issuerFailure };
|
|
240
|
+
} else if (error) {
|
|
211
241
|
resultState = { ok: false, error: `Authorization failed: ${errorDescription}` };
|
|
212
242
|
} else if (!code) {
|
|
213
243
|
resultState = { ok: false, error: "Missing authorization code" };
|
package/src/utils/oauth/index.ts
CHANGED
|
@@ -275,6 +275,11 @@ const builtInOAuthProviders: OAuthProviderInfo[] = [
|
|
|
275
275
|
name: "Vercel AI Gateway",
|
|
276
276
|
available: true,
|
|
277
277
|
},
|
|
278
|
+
{
|
|
279
|
+
id: "kiro",
|
|
280
|
+
name: "Kiro (Amazon Q Developer / CodeWhisperer)",
|
|
281
|
+
available: true,
|
|
282
|
+
},
|
|
278
283
|
];
|
|
279
284
|
|
|
280
285
|
const customOAuthProviders = new Map<string, OAuthProviderInterface>();
|
|
@@ -375,6 +380,11 @@ export async function refreshOAuthToken(
|
|
|
375
380
|
newCredentials = await refreshGlmZcodeToken(credentials);
|
|
376
381
|
break;
|
|
377
382
|
}
|
|
383
|
+
case "kiro": {
|
|
384
|
+
const { refreshKiroToken } = await import("./kiro");
|
|
385
|
+
newCredentials = await refreshKiroToken(credentials);
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
378
388
|
case "kilo":
|
|
379
389
|
case "perplexity":
|
|
380
390
|
case "huggingface":
|
|
@@ -487,7 +497,10 @@ export async function getOAuthApiKey(
|
|
|
487
497
|
}
|
|
488
498
|
// For providers that need request-time credential metadata, return JSON.
|
|
489
499
|
const needsStructuredApiKey =
|
|
490
|
-
provider === "github-copilot" ||
|
|
500
|
+
provider === "github-copilot" ||
|
|
501
|
+
provider === "google-gemini-cli" ||
|
|
502
|
+
provider === "google-antigravity" ||
|
|
503
|
+
provider === "kiro";
|
|
491
504
|
const apiKey = needsStructuredApiKey
|
|
492
505
|
? JSON.stringify({
|
|
493
506
|
token: creds.access,
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kiro (Amazon Q Developer / CodeWhisperer) OAuth flow via AWS SSO OIDC.
|
|
3
|
+
*
|
|
4
|
+
* Implements the device-code authorization flow and token refresh using the
|
|
5
|
+
* published AWS SSO OIDC service model (botocore sso-oidc/2019-06-10).
|
|
6
|
+
*
|
|
7
|
+
* Clean-room: derived from the published Amazon SSO OIDC service model shapes
|
|
8
|
+
* and AWS public documentation, not from any third-party reference.
|
|
9
|
+
*/
|
|
10
|
+
import { scheduler } from "node:timers/promises";
|
|
11
|
+
import type { OAuthCredentials } from "./types";
|
|
12
|
+
|
|
13
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
14
|
+
// Published SSO OIDC endpoints and constants
|
|
15
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/** Builder ID start URL for device authorization. */
|
|
18
|
+
const BUILDER_ID_START_URL = "https://view.awsapps.com/start";
|
|
19
|
+
|
|
20
|
+
/** Default AWS region for SSO OIDC. */
|
|
21
|
+
const DEFAULT_REGION = "us-east-1";
|
|
22
|
+
|
|
23
|
+
/** Client registration metadata for the Gajae Code application. */
|
|
24
|
+
const CLIENT_NAME = "gajae-code";
|
|
25
|
+
const CLIENT_TYPE = "public";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Scopes requested for CodeWhisperer / Amazon Q access. These are the published
|
|
29
|
+
* scopes from the AWS SSO OIDC model for the CodeWhisperer service.
|
|
30
|
+
*/
|
|
31
|
+
const CODEWHISPERER_SCOPES = [
|
|
32
|
+
"codewhisperer:completions",
|
|
33
|
+
"codewhisperer:analysis",
|
|
34
|
+
"codewhisperer:conversations",
|
|
35
|
+
"codewhisperer:transformations",
|
|
36
|
+
"codewhisperer:taskassist",
|
|
37
|
+
"sso:account:access",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
// Wire types (matching published SSO OIDC service model)
|
|
42
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
interface RegisterClientResponse {
|
|
45
|
+
clientId: string;
|
|
46
|
+
clientSecret: string;
|
|
47
|
+
clientIdIssuedAt: number;
|
|
48
|
+
clientSecretExpiresAt: number;
|
|
49
|
+
authorizationEndpoint?: string;
|
|
50
|
+
tokenEndpoint?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface StartDeviceAuthorizationResponse {
|
|
54
|
+
deviceCode: string;
|
|
55
|
+
userCode: string;
|
|
56
|
+
verificationUri: string;
|
|
57
|
+
verificationUriComplete?: string;
|
|
58
|
+
interval: number;
|
|
59
|
+
expiresIn: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface CreateTokenSuccess {
|
|
63
|
+
accessToken: string;
|
|
64
|
+
tokenType: string;
|
|
65
|
+
expiresIn: number;
|
|
66
|
+
refreshToken?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface CreateTokenError {
|
|
70
|
+
error: string;
|
|
71
|
+
error_description?: string;
|
|
72
|
+
error_uri?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
76
|
+
// Typed SSO OIDC error names from the published service model
|
|
77
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
const SSO_OIDC_FATAL_ERRORS = new Set([
|
|
80
|
+
"access_denied_exception",
|
|
81
|
+
"expired_token_exception",
|
|
82
|
+
"internal_server_exception",
|
|
83
|
+
"invalid_client_exception",
|
|
84
|
+
"invalid_client_metadata_exception",
|
|
85
|
+
"invalid_grant_exception",
|
|
86
|
+
"invalid_redirect_uri_exception",
|
|
87
|
+
"invalid_request_exception",
|
|
88
|
+
"invalid_request_region_exception",
|
|
89
|
+
"invalid_scope_exception",
|
|
90
|
+
"unauthorized_client_exception",
|
|
91
|
+
"unsupported_grant_type_exception",
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
95
|
+
// Registration cache (client registration is reusable until expiry)
|
|
96
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
interface ClientRegistration {
|
|
99
|
+
clientId: string;
|
|
100
|
+
clientSecret: string;
|
|
101
|
+
expiresAt: number; // epoch ms
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
let cachedRegistration: ClientRegistration | undefined;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Register a public SSO OIDC client. Registration responses include an expiry
|
|
108
|
+
* timestamp (`clientSecretExpiresAt`); we cache until then to avoid re-registering
|
|
109
|
+
* on every login attempt.
|
|
110
|
+
*
|
|
111
|
+
* The SSO OIDC `RegisterClient` endpoint is public (no authentication required).
|
|
112
|
+
*/
|
|
113
|
+
export async function registerClient(
|
|
114
|
+
region: string,
|
|
115
|
+
startUrl: string,
|
|
116
|
+
signal?: AbortSignal,
|
|
117
|
+
): Promise<ClientRegistration> {
|
|
118
|
+
if (cachedRegistration && Date.now() < cachedRegistration.expiresAt - 60_000) {
|
|
119
|
+
return cachedRegistration;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const url = ssoOidcEndpoint(region, "/client/register");
|
|
123
|
+
const body = {
|
|
124
|
+
clientName: CLIENT_NAME,
|
|
125
|
+
clientType: CLIENT_TYPE,
|
|
126
|
+
scopes: CODEWHISPERER_SCOPES,
|
|
127
|
+
grantTypes: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"],
|
|
128
|
+
redirectUris: [],
|
|
129
|
+
issuerUrl: startUrl,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const response = await fetchOidc(url, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: { "Content-Type": "application/json" },
|
|
135
|
+
body: JSON.stringify(body),
|
|
136
|
+
signal,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const data = (await response.json()) as RegisterClientResponse;
|
|
140
|
+
if (!data.clientId || !data.clientSecret) {
|
|
141
|
+
throw new Error("SSO OIDC RegisterClient: missing clientId or clientSecret");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
cachedRegistration = {
|
|
145
|
+
clientId: data.clientId,
|
|
146
|
+
clientSecret: data.clientSecret,
|
|
147
|
+
// `clientSecretExpiresAt` is epoch seconds in the published model
|
|
148
|
+
expiresAt: data.clientSecretExpiresAt * 1000,
|
|
149
|
+
};
|
|
150
|
+
return cachedRegistration;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Drop cached client registration — used by tests. */
|
|
154
|
+
export function clearClientRegistrationCache(): void {
|
|
155
|
+
cachedRegistration = undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
159
|
+
// Device authorization flow
|
|
160
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Start device authorization. The SSO OIDC `StartDeviceAuthorization` endpoint
|
|
164
|
+
* is public (requires registered clientId/clientSecret, not SigV4).
|
|
165
|
+
*/
|
|
166
|
+
export async function startDeviceAuthorization(
|
|
167
|
+
region: string,
|
|
168
|
+
startUrl: string,
|
|
169
|
+
registration: ClientRegistration,
|
|
170
|
+
signal?: AbortSignal,
|
|
171
|
+
): Promise<StartDeviceAuthorizationResponse> {
|
|
172
|
+
const url = ssoOidcEndpoint(region, "/device_authorization");
|
|
173
|
+
const body = {
|
|
174
|
+
clientId: registration.clientId,
|
|
175
|
+
clientSecret: registration.clientSecret,
|
|
176
|
+
startUrl,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const response = await fetchOidc(url, {
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: { "Content-Type": "application/json" },
|
|
182
|
+
body: JSON.stringify(body),
|
|
183
|
+
signal,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const data = (await response.json()) as StartDeviceAuthorizationResponse;
|
|
187
|
+
if (!data.deviceCode || !data.userCode || !data.verificationUri) {
|
|
188
|
+
throw new Error("SSO OIDC StartDeviceAuthorization: missing required fields");
|
|
189
|
+
}
|
|
190
|
+
return data;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Poll `CreateToken` until the user completes authorization or the device code
|
|
195
|
+
* expires. Handles `authorization_pending` (continue polling) and `slow_down`
|
|
196
|
+
* (increase interval) per the published SSO OIDC model.
|
|
197
|
+
*/
|
|
198
|
+
export async function pollForToken(
|
|
199
|
+
region: string,
|
|
200
|
+
registration: ClientRegistration,
|
|
201
|
+
deviceCode: string,
|
|
202
|
+
intervalSeconds: number,
|
|
203
|
+
expiresInSeconds: number,
|
|
204
|
+
signal?: AbortSignal,
|
|
205
|
+
): Promise<CreateTokenSuccess> {
|
|
206
|
+
const url = ssoOidcEndpoint(region, "/token");
|
|
207
|
+
const deadline = Date.now() + expiresInSeconds * 1000;
|
|
208
|
+
let currentInterval = Math.max(intervalSeconds, 1) * 1000;
|
|
209
|
+
|
|
210
|
+
while (Date.now() < deadline) {
|
|
211
|
+
if (signal?.aborted) throw new Error("Login cancelled");
|
|
212
|
+
|
|
213
|
+
const remainingMs = deadline - Date.now();
|
|
214
|
+
const waitMs = Math.min(currentInterval, remainingMs);
|
|
215
|
+
try {
|
|
216
|
+
await scheduler.wait(waitMs, { signal });
|
|
217
|
+
} catch {
|
|
218
|
+
throw new Error("Login cancelled");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (signal?.aborted) throw new Error("Login cancelled");
|
|
222
|
+
|
|
223
|
+
const body = {
|
|
224
|
+
clientId: registration.clientId,
|
|
225
|
+
clientSecret: registration.clientSecret,
|
|
226
|
+
grantType: "urn:ietf:params:oauth:grant-type:device_code",
|
|
227
|
+
deviceCode,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const response = await fetchOidc(url, {
|
|
231
|
+
method: "POST",
|
|
232
|
+
headers: { "Content-Type": "application/json" },
|
|
233
|
+
body: JSON.stringify(body),
|
|
234
|
+
signal,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const data = (await response.json()) as CreateTokenSuccess | CreateTokenError;
|
|
238
|
+
|
|
239
|
+
if ("accessToken" in data && typeof data.accessToken === "string") {
|
|
240
|
+
return data;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if ("error" in data) {
|
|
244
|
+
const errorCode = data.error;
|
|
245
|
+
if (errorCode === "authorization_pending") continue;
|
|
246
|
+
if (errorCode === "slow_down") {
|
|
247
|
+
currentInterval = Math.ceil(currentInterval * 1.5);
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (SSO_OIDC_FATAL_ERRORS.has(errorCode)) {
|
|
251
|
+
const desc = data.error_description ? `: ${data.error_description}` : "";
|
|
252
|
+
throw new Error(`SSO OIDC token error: ${errorCode}${desc}`);
|
|
253
|
+
}
|
|
254
|
+
// Unknown error — fail closed
|
|
255
|
+
const desc = data.error_description ? `: ${data.error_description}` : "";
|
|
256
|
+
throw new Error(`SSO OIDC unrecognized token error: ${errorCode}${desc}`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
throw new Error("SSO OIDC CreateToken: unrecognized response shape");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
throw new Error("SSO OIDC device authorization timed out");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
266
|
+
// Token refresh
|
|
267
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Refresh an expired access token using the stored refresh token via
|
|
271
|
+
* `CreateToken` with `grantType: "refresh_token"`.
|
|
272
|
+
*
|
|
273
|
+
* Rotation is published behavior: the response includes a new `refreshToken`.
|
|
274
|
+
* If the server does not return a new one, the old refresh token is retained.
|
|
275
|
+
*/
|
|
276
|
+
export async function refreshKiroToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
|
|
277
|
+
const region = DEFAULT_REGION;
|
|
278
|
+
|
|
279
|
+
// We need client registration to refresh. If we have a cached one, use it;
|
|
280
|
+
// otherwise re-register.
|
|
281
|
+
const registration = cachedRegistration ?? (await registerClient(region, BUILDER_ID_START_URL));
|
|
282
|
+
|
|
283
|
+
const url = ssoOidcEndpoint(region, "/token");
|
|
284
|
+
const body = {
|
|
285
|
+
clientId: registration.clientId,
|
|
286
|
+
clientSecret: registration.clientSecret,
|
|
287
|
+
grantType: "refresh_token",
|
|
288
|
+
refreshToken: credentials.refresh,
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const response = await fetchOidc(url, {
|
|
292
|
+
method: "POST",
|
|
293
|
+
headers: { "Content-Type": "application/json" },
|
|
294
|
+
body: JSON.stringify(body),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const data = (await response.json()) as CreateTokenSuccess | CreateTokenError;
|
|
298
|
+
|
|
299
|
+
if ("accessToken" in data && typeof data.accessToken === "string") {
|
|
300
|
+
const expiresAt = Date.now() + data.expiresIn * 1000;
|
|
301
|
+
return {
|
|
302
|
+
access: data.accessToken,
|
|
303
|
+
refresh: data.refreshToken ?? credentials.refresh,
|
|
304
|
+
expires: expiresAt,
|
|
305
|
+
enterpriseUrl: credentials.enterpriseUrl,
|
|
306
|
+
projectId: credentials.projectId,
|
|
307
|
+
email: credentials.email,
|
|
308
|
+
accountId: credentials.accountId,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if ("error" in data) {
|
|
313
|
+
const errorCode = data.error;
|
|
314
|
+
const desc = data.error_description ? `: ${data.error_description}` : "";
|
|
315
|
+
if (errorCode === "invalid_grant_exception" || errorCode === "invalid_grant") {
|
|
316
|
+
throw new Error(
|
|
317
|
+
`Kiro refresh token is invalid or expired. Run 'gjc auth-broker login kiro' to re-authenticate. (${errorCode}${desc})`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
if (errorCode === "expired_token_exception") {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`Kiro client registration has expired. Run 'gjc auth-broker login kiro' to re-authenticate. (${errorCode}${desc})`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
throw new Error(`Kiro token refresh failed: ${errorCode}${desc}`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
throw new Error("Kiro token refresh: unrecognized response");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
332
|
+
// Full login flow (device code)
|
|
333
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
export interface KiroLoginOptions {
|
|
336
|
+
onAuth: (url: string, instructions?: string) => void;
|
|
337
|
+
onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
|
|
338
|
+
onProgress?: (message: string) => void;
|
|
339
|
+
signal?: AbortSignal;
|
|
340
|
+
/** Override for tests. */
|
|
341
|
+
fetchImpl?: typeof globalThis.fetch;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export async function loginKiro(options: KiroLoginOptions): Promise<OAuthCredentials> {
|
|
345
|
+
const region = DEFAULT_REGION;
|
|
346
|
+
const startUrl = BUILDER_ID_START_URL;
|
|
347
|
+
|
|
348
|
+
if (options.signal?.aborted) throw new Error("Login cancelled");
|
|
349
|
+
|
|
350
|
+
options.onProgress?.("Registering client with AWS SSO OIDC...");
|
|
351
|
+
const registration = await registerClient(region, startUrl, options.signal);
|
|
352
|
+
|
|
353
|
+
if (options.signal?.aborted) throw new Error("Login cancelled");
|
|
354
|
+
|
|
355
|
+
options.onProgress?.("Requesting device authorization...");
|
|
356
|
+
const deviceAuth = await startDeviceAuthorization(region, startUrl, registration, options.signal);
|
|
357
|
+
|
|
358
|
+
const verificationUrl = deviceAuth.verificationUriComplete ?? deviceAuth.verificationUri;
|
|
359
|
+
options.onAuth(verificationUrl, `Enter code: ${deviceAuth.userCode}`);
|
|
360
|
+
|
|
361
|
+
options.onProgress?.("Waiting for authorization...");
|
|
362
|
+
const token = await pollForToken(
|
|
363
|
+
region,
|
|
364
|
+
registration,
|
|
365
|
+
deviceAuth.deviceCode,
|
|
366
|
+
deviceAuth.interval,
|
|
367
|
+
deviceAuth.expiresIn,
|
|
368
|
+
options.signal,
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
const expiresAt = Date.now() + token.expiresIn * 1000;
|
|
372
|
+
return {
|
|
373
|
+
access: token.accessToken,
|
|
374
|
+
refresh: token.refreshToken ?? "",
|
|
375
|
+
expires: expiresAt,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
380
|
+
// SSO cache import (reuse existing ~/.aws/sso/cache/*.json pattern)
|
|
381
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
382
|
+
|
|
383
|
+
import * as fs from "node:fs";
|
|
384
|
+
import * as path from "node:path";
|
|
385
|
+
|
|
386
|
+
interface SsoCachedAccessToken {
|
|
387
|
+
accessToken: string;
|
|
388
|
+
expiresAt?: number;
|
|
389
|
+
startUrl?: string;
|
|
390
|
+
region?: string;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Attempt to import a cached SSO access token from `~/.aws/sso/cache/`.
|
|
395
|
+
* Returns the token if a valid (non-expired) one exists, otherwise undefined.
|
|
396
|
+
*
|
|
397
|
+
* This reuses the documented AWS CLI SSO cache location, not any third-party
|
|
398
|
+
* credential store.
|
|
399
|
+
*/
|
|
400
|
+
export function importSsoCacheToken(): OAuthCredentials | undefined {
|
|
401
|
+
const homeDir = process.env.HOME ?? process.env.USERPROFILE;
|
|
402
|
+
if (!homeDir) return undefined;
|
|
403
|
+
const cacheDir = path.join(homeDir, ".aws", "sso", "cache");
|
|
404
|
+
|
|
405
|
+
let files: string[];
|
|
406
|
+
try {
|
|
407
|
+
files = fs.readdirSync(cacheDir).filter(f => f.endsWith(".json"));
|
|
408
|
+
} catch {
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for (const file of files) {
|
|
413
|
+
try {
|
|
414
|
+
const raw = fs.readFileSync(path.join(cacheDir, file), "utf8");
|
|
415
|
+
const cached = JSON.parse(raw) as SsoCachedAccessToken;
|
|
416
|
+
if (!cached.accessToken) continue;
|
|
417
|
+
if (cached.expiresAt && cached.expiresAt * 1000 < Date.now()) continue;
|
|
418
|
+
return {
|
|
419
|
+
access: cached.accessToken,
|
|
420
|
+
refresh: "",
|
|
421
|
+
expires: cached.expiresAt ? cached.expiresAt * 1000 : Date.now() + 3600_000,
|
|
422
|
+
};
|
|
423
|
+
} catch {}
|
|
424
|
+
}
|
|
425
|
+
return undefined;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
429
|
+
// Helpers
|
|
430
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
function ssoOidcEndpoint(region: string, pathSuffix: string): string {
|
|
433
|
+
return `https://oidc.${region}.amazonaws.com${pathSuffix}`;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function fetchOidc(url: string, init: RequestInit & { signal?: AbortSignal }): Promise<Response> {
|
|
437
|
+
const response = await fetch(url, init);
|
|
438
|
+
if (!response.ok) {
|
|
439
|
+
let errorBody = "";
|
|
440
|
+
try {
|
|
441
|
+
errorBody = await response.text();
|
|
442
|
+
} catch {}
|
|
443
|
+
throw new Error(
|
|
444
|
+
`SSO OIDC request to ${url} failed: ${response.status} ${response.statusText}: ${errorBody.slice(0, 500)}`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
return response;
|
|
448
|
+
}
|
|
@@ -8,9 +8,8 @@ export const loginSynthetic = createApiKeyLogin({
|
|
|
8
8
|
promptMessage: "Paste your Synthetic API key",
|
|
9
9
|
placeholder: "sk-...",
|
|
10
10
|
validation: {
|
|
11
|
-
kind: "
|
|
11
|
+
kind: "models-endpoint",
|
|
12
12
|
provider: "Synthetic",
|
|
13
|
-
|
|
14
|
-
model: "hf:moonshotai/Kimi-K2.5",
|
|
13
|
+
modelsUrl: "https://api.synthetic.new/openai/v1/models",
|
|
15
14
|
},
|
|
16
15
|
});
|
package/src/utils/oauth/types.ts
CHANGED
package/src/utils/parse-bind.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* paths. Centralized so the two servers can't drift on what they accept (the
|
|
4
4
|
* gateway used to silently allow empty hostnames; this fixes it).
|
|
5
5
|
*/
|
|
6
|
+
import { isIP } from "node:net";
|
|
6
7
|
|
|
7
8
|
export interface ParsedBind {
|
|
8
9
|
hostname: string;
|
|
@@ -52,3 +53,29 @@ export function parseBind(raw: string): ParsedBind {
|
|
|
52
53
|
}
|
|
53
54
|
return { hostname: hostPart, port: parsePort(portPart, raw) };
|
|
54
55
|
}
|
|
56
|
+
|
|
57
|
+
/** True for loopback-only hostnames the auth servers may bind without credentials. */
|
|
58
|
+
export function isLoopbackHostname(hostname: string): boolean {
|
|
59
|
+
const normalized = hostname
|
|
60
|
+
.trim()
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.replace(/^\[|\]$/g, "");
|
|
63
|
+
if (normalized === "localhost" || normalized === "::1") return true;
|
|
64
|
+
// Strict numeric IPv4 loopback literals only: a bare prefix match would
|
|
65
|
+
// accept attacker-controlled names like `127.evil.example`, and short/hex
|
|
66
|
+
// IPv4 forms (`127.1`, `0x7f.1`) are rejected by the parser anyway.
|
|
67
|
+
return isIP(normalized) === 4 && normalized.split(".")[0] === "127";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Fail closed when an unauthenticated auth server (empty bearer token set)
|
|
72
|
+
* would bind a non-loopback address: that exposes credential operations to the
|
|
73
|
+
* network with no proof of possession.
|
|
74
|
+
*/
|
|
75
|
+
export function assertAuthenticatedOrLoopback(bind: ParsedBind, bearerTokenCount: number, serverName: string): void {
|
|
76
|
+
if (bearerTokenCount > 0) return;
|
|
77
|
+
if (isLoopbackHostname(bind.hostname)) return;
|
|
78
|
+
throw new Error(
|
|
79
|
+
`${serverName} refuses to bind ${bind.hostname}:${bind.port} without bearer tokens; unauthenticated mode is loopback-only.`,
|
|
80
|
+
);
|
|
81
|
+
}
|