dsh-coding-subscription-oauth 0.5.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 +136 -0
- package/CONTRIBUTING.md +109 -0
- package/INSTALL.md +220 -0
- package/LICENSE +19 -0
- package/NOTICE +11 -0
- package/README.de.md +254 -0
- package/README.es.md +254 -0
- package/README.fr.md +254 -0
- package/README.ja.md +254 -0
- package/README.ko.md +254 -0
- package/README.md +286 -0
- package/README.pt-BR.md +254 -0
- package/README.ru.md +254 -0
- package/README.zh-CN.md +271 -0
- package/cordis.patch.yml +13 -0
- package/docs/00-project-rules.md +195 -0
- package/docs/02-architecture.md +132 -0
- package/docs/02-architecture.zh-CN.md +128 -0
- package/lib/adapter.d.ts +24 -0
- package/lib/adapter.d.ts.map +1 -0
- package/lib/alias-adapter.d.ts +43 -0
- package/lib/alias-adapter.d.ts.map +1 -0
- package/lib/auth-routes.d.ts +155 -0
- package/lib/auth-routes.d.ts.map +1 -0
- package/lib/auth.d.ts +29 -0
- package/lib/auth.d.ts.map +1 -0
- package/lib/bin.d.ts +6 -0
- package/lib/bin.d.ts.map +1 -0
- package/lib/bin.js +27591 -0
- package/lib/bin.js.map +7 -0
- package/lib/capability-routes.d.ts +43 -0
- package/lib/capability-routes.d.ts.map +1 -0
- package/lib/capability-runtime.d.ts +56 -0
- package/lib/capability-runtime.d.ts.map +1 -0
- package/lib/capability-settings.d.ts +263 -0
- package/lib/capability-settings.d.ts.map +1 -0
- package/lib/capability-tools.d.ts +50 -0
- package/lib/capability-tools.d.ts.map +1 -0
- package/lib/catalog.d.ts +53 -0
- package/lib/catalog.d.ts.map +1 -0
- package/lib/client.js +3 -0
- package/lib/client.js.map +7 -0
- package/lib/codex-http.d.ts +77 -0
- package/lib/codex-http.d.ts.map +1 -0
- package/lib/codex-images.d.ts +109 -0
- package/lib/codex-images.d.ts.map +1 -0
- package/lib/codex-model-capabilities.d.ts +112 -0
- package/lib/codex-model-capabilities.d.ts.map +1 -0
- package/lib/codex-search.d.ts +96 -0
- package/lib/codex-search.d.ts.map +1 -0
- package/lib/codex-usage.d.ts +79 -0
- package/lib/codex-usage.d.ts.map +1 -0
- package/lib/gateway-anthropic-messages.d.ts +8 -0
- package/lib/gateway-anthropic-messages.d.ts.map +1 -0
- package/lib/gateway-auth.d.ts +22 -0
- package/lib/gateway-auth.d.ts.map +1 -0
- package/lib/gateway-backend.d.ts +62 -0
- package/lib/gateway-backend.d.ts.map +1 -0
- package/lib/gateway-body.d.ts +9 -0
- package/lib/gateway-body.d.ts.map +1 -0
- package/lib/gateway-config.d.ts +24 -0
- package/lib/gateway-config.d.ts.map +1 -0
- package/lib/gateway-http.d.ts +16 -0
- package/lib/gateway-http.d.ts.map +1 -0
- package/lib/gateway-openai-chat.d.ts +8 -0
- package/lib/gateway-openai-chat.d.ts.map +1 -0
- package/lib/gateway-openai-responses.d.ts +8 -0
- package/lib/gateway-openai-responses.d.ts.map +1 -0
- package/lib/gateway-parse.d.ts +10 -0
- package/lib/gateway-parse.d.ts.map +1 -0
- package/lib/gateway-protocol.d.ts +47 -0
- package/lib/gateway-protocol.d.ts.map +1 -0
- package/lib/gateway-routes.d.ts +21 -0
- package/lib/gateway-routes.d.ts.map +1 -0
- package/lib/gateway.d.ts +48 -0
- package/lib/gateway.d.ts.map +1 -0
- package/lib/grok-imagine.d.ts +271 -0
- package/lib/grok-imagine.d.ts.map +1 -0
- package/lib/grok-import.d.ts +21 -0
- package/lib/grok-import.d.ts.map +1 -0
- package/lib/http-json.d.ts +10 -0
- package/lib/http-json.d.ts.map +1 -0
- package/lib/ids.d.ts +33 -0
- package/lib/ids.d.ts.map +1 -0
- package/lib/imagine-routes.d.ts +59 -0
- package/lib/imagine-routes.d.ts.map +1 -0
- package/lib/index.d.ts +69 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +35355 -0
- package/lib/index.js.map +7 -0
- package/lib/invariant.d.ts +9 -0
- package/lib/invariant.d.ts.map +1 -0
- package/lib/invariant.js +14 -0
- package/lib/invariant.js.map +7 -0
- package/lib/kimi-errors.d.ts +13 -0
- package/lib/kimi-errors.d.ts.map +1 -0
- package/lib/media-store.d.ts +130 -0
- package/lib/media-store.d.ts.map +1 -0
- package/lib/oauth-import-routes.d.ts +52 -0
- package/lib/oauth-import-routes.d.ts.map +1 -0
- package/lib/oauth-providers.d.ts +26 -0
- package/lib/oauth-providers.d.ts.map +1 -0
- package/lib/oauth-session.d.ts +40 -0
- package/lib/oauth-session.d.ts.map +1 -0
- package/lib/oauth-sources.d.ts +205 -0
- package/lib/oauth-sources.d.ts.map +1 -0
- package/lib/oauth.d.ts +79 -0
- package/lib/oauth.d.ts.map +1 -0
- package/lib/provider.d.ts +38 -0
- package/lib/provider.d.ts.map +1 -0
- package/lib/proxy.d.ts +17 -0
- package/lib/proxy.d.ts.map +1 -0
- package/lib/redact.d.ts +5 -0
- package/lib/redact.d.ts.map +1 -0
- package/lib/session.d.ts +40 -0
- package/lib/session.d.ts.map +1 -0
- package/lib/store.d.ts +46 -0
- package/lib/store.d.ts.map +1 -0
- package/lib/web-origin.d.ts +10 -0
- package/lib/web-origin.d.ts.map +1 -0
- package/lib/web-routes.d.ts +20 -0
- package/lib/web-routes.d.ts.map +1 -0
- package/package.json +185 -0
- package/patches/dsh-agy@0.1.2.patch +25 -0
- package/scripts/release.mjs +166 -0
- package/scripts/smoke-deployed-routes.mjs +146 -0
- package/scripts/verify-deployed-catalog.mjs +87 -0
- package/src/adapter.ts +282 -0
- package/src/alias-adapter.ts +152 -0
- package/src/auth-routes.ts +871 -0
- package/src/auth.ts +67 -0
- package/src/bin.ts +350 -0
- package/src/capability-routes.ts +275 -0
- package/src/capability-runtime.ts +313 -0
- package/src/capability-settings.ts +657 -0
- package/src/capability-tools.ts +666 -0
- package/src/catalog.ts +271 -0
- package/src/client/GrokBuildSettings.tsx +2221 -0
- package/src/client/index.tsx +37 -0
- package/src/client/locales.ts +421 -0
- package/src/codex-http.ts +447 -0
- package/src/codex-images.ts +485 -0
- package/src/codex-model-capabilities.ts +320 -0
- package/src/codex-search.ts +245 -0
- package/src/codex-usage.ts +263 -0
- package/src/gateway-anthropic-messages.ts +84 -0
- package/src/gateway-auth.ts +100 -0
- package/src/gateway-backend.ts +274 -0
- package/src/gateway-body.ts +49 -0
- package/src/gateway-config.ts +76 -0
- package/src/gateway-http.ts +104 -0
- package/src/gateway-openai-chat.ts +124 -0
- package/src/gateway-openai-responses.ts +53 -0
- package/src/gateway-parse.ts +224 -0
- package/src/gateway-protocol.ts +52 -0
- package/src/gateway-routes.ts +152 -0
- package/src/gateway.ts +242 -0
- package/src/grok-imagine.ts +1627 -0
- package/src/grok-import.ts +151 -0
- package/src/http-json.ts +82 -0
- package/src/ids.ts +45 -0
- package/src/imagine-routes.ts +461 -0
- package/src/index.ts +598 -0
- package/src/invariant.ts +17 -0
- package/src/kimi-errors.ts +26 -0
- package/src/media-store.ts +927 -0
- package/src/oauth-import-routes.ts +314 -0
- package/src/oauth-providers.ts +152 -0
- package/src/oauth-session.ts +183 -0
- package/src/oauth-sources.ts +1104 -0
- package/src/oauth.ts +620 -0
- package/src/provider.ts +128 -0
- package/src/proxy.ts +99 -0
- package/src/redact.ts +72 -0
- package/src/session.ts +218 -0
- package/src/store.ts +217 -0
- package/src/web-origin.ts +60 -0
- package/src/web-routes.ts +75 -0
package/src/oauth.ts
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grok Build OAuth authorization-code + PKCE flow (primary login path).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the official Grok CLI: OIDC discovery, S256 PKCE, dual-channel code
|
|
5
|
+
* capture (loopback listener + manual paste), form POST token exchange.
|
|
6
|
+
* The device-code flow remains the fallback (see auth.ts / bin.ts).
|
|
7
|
+
* @module dsh-coding-subscription-oauth/oauth
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Buffer } from "node:buffer";
|
|
11
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
12
|
+
import type { Server } from "node:http";
|
|
13
|
+
import { createServer } from "node:http";
|
|
14
|
+
import type { OAuthCredential } from "@earendil-works/pi-ai";
|
|
15
|
+
import { codingOAuthProxyUnreachableHint } from "./proxy.ts";
|
|
16
|
+
import { safeMessage } from "./redact.ts";
|
|
17
|
+
|
|
18
|
+
/** OIDC issuer for both Grok CLI and Grok Build. */
|
|
19
|
+
export const GROK_BUILD_OAUTH_ISSUER = "https://auth.x.ai";
|
|
20
|
+
|
|
21
|
+
/** Approved Grok OIDC issuer origin. Discovery authorization/token endpoints must remain on this origin. */
|
|
22
|
+
const APPROVED_GROK_ISSUER_ORIGIN = "https://auth.x.ai";
|
|
23
|
+
|
|
24
|
+
/** Hard ceiling on the discovery document body. Discovery is small JSON; anything larger is hostile. */
|
|
25
|
+
const DISCOVERY_MAX_BYTES = 64 * 1024;
|
|
26
|
+
const TOKEN_RESPONSE_MAX_BYTES = 64 * 1024;
|
|
27
|
+
|
|
28
|
+
/** Hard ceiling on a user-pasted authorization code extracted from arbitrary input. */
|
|
29
|
+
const EXTRACTED_CODE_MAX_LENGTH = 1024;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Public client id known to work for the device flow; reused as the default
|
|
33
|
+
* for the authorization-code flow until the official CLI's own id is
|
|
34
|
+
* confirmed (T2.1). Override with GROK_OAUTH2_CLIENT_ID.
|
|
35
|
+
*/
|
|
36
|
+
export const GROK_BUILD_OAUTH_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
|
|
37
|
+
|
|
38
|
+
/** Scopes the official CLI requests (grok-cli:access = CLI inference pass). */
|
|
39
|
+
export const GROK_BUILD_OAUTH_SCOPE = "openid profile email offline_access grok-cli:access api:access";
|
|
40
|
+
|
|
41
|
+
/** Default loopback port observed for the official CLI (codex-app-transfer). */
|
|
42
|
+
export const GROK_BUILD_OAUTH_DEFAULT_PORT = 56121;
|
|
43
|
+
|
|
44
|
+
const DISCOVERY_PATH = "/.well-known/openid-configuration";
|
|
45
|
+
const DEFAULT_LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
46
|
+
const PORT_SCAN_ATTEMPTS = 10;
|
|
47
|
+
|
|
48
|
+
export type GrokBuildOAuthErrorCode =
|
|
49
|
+
| "discovery"
|
|
50
|
+
| "loopback"
|
|
51
|
+
| "state_mismatch"
|
|
52
|
+
| "token_exchange"
|
|
53
|
+
| "cancelled"
|
|
54
|
+
| "timeout";
|
|
55
|
+
|
|
56
|
+
/** OAuth failure with a stable, secret-free machine code. */
|
|
57
|
+
export class GrokBuildOAuthError extends Error {
|
|
58
|
+
readonly code: GrokBuildOAuthErrorCode;
|
|
59
|
+
|
|
60
|
+
constructor(code: GrokBuildOAuthErrorCode, message: string) {
|
|
61
|
+
super(`grok-build oauth: ${message}`);
|
|
62
|
+
this.name = "GrokBuildOAuthError";
|
|
63
|
+
this.code = code;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface GrokBuildOAuthParams {
|
|
68
|
+
issuer: string;
|
|
69
|
+
clientId: string;
|
|
70
|
+
scope: string;
|
|
71
|
+
/** Loopback port for the redirect URI; falls forward on EADDRINUSE. */
|
|
72
|
+
port: number;
|
|
73
|
+
/** Optional xAI extension parameter. */
|
|
74
|
+
referrer?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Resolve OAuth parameters from overrides then GROK_OAUTH2_* env vars. */
|
|
78
|
+
export function resolveOAuthParams(overrides: Partial<GrokBuildOAuthParams> = {}): GrokBuildOAuthParams {
|
|
79
|
+
const env = process.env;
|
|
80
|
+
return {
|
|
81
|
+
issuer: overrides.issuer ?? env["GROK_OAUTH2_ISSUER"] ?? GROK_BUILD_OAUTH_ISSUER,
|
|
82
|
+
clientId: overrides.clientId ?? env["GROK_OAUTH2_CLIENT_ID"] ?? GROK_BUILD_OAUTH_CLIENT_ID,
|
|
83
|
+
scope: overrides.scope ?? env["GROK_OAUTH2_SCOPES"] ?? GROK_BUILD_OAUTH_SCOPE,
|
|
84
|
+
port:
|
|
85
|
+
overrides.port ??
|
|
86
|
+
(env["GROK_OAUTH2_PORT"] !== undefined ? Number(env["GROK_OAUTH2_PORT"]) : GROK_BUILD_OAUTH_DEFAULT_PORT),
|
|
87
|
+
...((overrides.referrer ?? env["GROK_OAUTH2_REFERRER"]) !== undefined
|
|
88
|
+
? { referrer: (overrides.referrer ?? env["GROK_OAUTH2_REFERRER"]) as string }
|
|
89
|
+
: {}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface DiscoveryDocument {
|
|
94
|
+
issuer: string;
|
|
95
|
+
authorization_endpoint: string;
|
|
96
|
+
token_endpoint: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let discoveryCache: { issuer: string; document: DiscoveryDocument; fetchedAt: number } | undefined;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Validate that an issuer string points at the approved Grok OIDC origin. HTTPS
|
|
103
|
+
* is required in production; the loopback tests opt in via
|
|
104
|
+
* {@link DiscoveryFetchOptions.allowInsecureLoopbackIssuer} so the existing mock
|
|
105
|
+
* IdP can keep using `http://127.0.0.1`.
|
|
106
|
+
*/
|
|
107
|
+
function assertApprovedIssuer(issuer: string, options: DiscoveryFetchOptions): URL {
|
|
108
|
+
let parsed: URL;
|
|
109
|
+
try {
|
|
110
|
+
parsed = new URL(issuer);
|
|
111
|
+
} catch {
|
|
112
|
+
throw new GrokBuildOAuthError("discovery", "configured issuer is not a valid URL");
|
|
113
|
+
}
|
|
114
|
+
const isLoopback = parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]" || parsed.hostname === "::1";
|
|
115
|
+
if (parsed.protocol !== "https:" && !(options.allowInsecureLoopbackIssuer === true && isLoopback)) {
|
|
116
|
+
throw new GrokBuildOAuthError(
|
|
117
|
+
"discovery",
|
|
118
|
+
`configured issuer must use HTTPS${options.allowInsecureLoopbackIssuer === true ? " (loopback test override ignored for non-loopback host)" : ""}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (!isLoopback && parsed.origin !== APPROVED_GROK_ISSUER_ORIGIN) {
|
|
122
|
+
throw new GrokBuildOAuthError("discovery", "configured issuer is not on the approved Grok OIDC origin");
|
|
123
|
+
}
|
|
124
|
+
if (
|
|
125
|
+
parsed.username !== "" ||
|
|
126
|
+
parsed.password !== "" ||
|
|
127
|
+
parsed.pathname !== "/" ||
|
|
128
|
+
parsed.search !== "" ||
|
|
129
|
+
parsed.hash !== ""
|
|
130
|
+
) {
|
|
131
|
+
throw new GrokBuildOAuthError("discovery", "configured issuer must be an origin URL without userinfo or path");
|
|
132
|
+
}
|
|
133
|
+
return parsed;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Validate a discovery endpoint URL is on the approved issuer origin. */
|
|
137
|
+
function assertApprovedEndpointUrl(
|
|
138
|
+
value: string,
|
|
139
|
+
role: "authorization_endpoint" | "token_endpoint",
|
|
140
|
+
issuer: URL,
|
|
141
|
+
options: DiscoveryFetchOptions,
|
|
142
|
+
): URL {
|
|
143
|
+
let parsed: URL;
|
|
144
|
+
try {
|
|
145
|
+
parsed = new URL(value);
|
|
146
|
+
} catch {
|
|
147
|
+
throw new GrokBuildOAuthError("discovery", `discovery ${role} is not a valid URL`);
|
|
148
|
+
}
|
|
149
|
+
const isLoopback = parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]" || parsed.hostname === "::1";
|
|
150
|
+
if (parsed.protocol !== "https:" && !(options.allowInsecureLoopbackIssuer === true && isLoopback)) {
|
|
151
|
+
throw new GrokBuildOAuthError("discovery", `discovery ${role} must use HTTPS`);
|
|
152
|
+
}
|
|
153
|
+
if (parsed.username !== "" || parsed.password !== "") {
|
|
154
|
+
throw new GrokBuildOAuthError("discovery", `discovery ${role} must not carry userinfo`);
|
|
155
|
+
}
|
|
156
|
+
// Endpoint origin must always equal the already approved issuer origin. The
|
|
157
|
+
// test-only loopback override relaxes HTTPS, never host or port pinning.
|
|
158
|
+
if (parsed.origin !== issuer.origin) {
|
|
159
|
+
throw new GrokBuildOAuthError("discovery", `discovery ${role} escaped the approved issuer origin`);
|
|
160
|
+
}
|
|
161
|
+
return parsed;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface DiscoveryFetchOptions {
|
|
165
|
+
/** Loopback-only test override: permit `http://127.0.0.1`/`http://[::1]` issuers. */
|
|
166
|
+
readonly allowInsecureLoopbackIssuer?: boolean;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Fetch (and cache for the process) the issuer's discovery document. */
|
|
170
|
+
export async function discoverOAuthEndpoints(
|
|
171
|
+
issuer: string,
|
|
172
|
+
signal?: AbortSignal,
|
|
173
|
+
options: DiscoveryFetchOptions = {},
|
|
174
|
+
): Promise<DiscoveryDocument> {
|
|
175
|
+
const approvedIssuer = assertApprovedIssuer(issuer, options);
|
|
176
|
+
if (
|
|
177
|
+
discoveryCache !== undefined &&
|
|
178
|
+
discoveryCache.issuer === approvedIssuer.href &&
|
|
179
|
+
Date.now() - discoveryCache.fetchedAt < 60 * 60 * 1000
|
|
180
|
+
) {
|
|
181
|
+
return discoveryCache.document;
|
|
182
|
+
}
|
|
183
|
+
let response: Response;
|
|
184
|
+
try {
|
|
185
|
+
response = await fetch(new URL(DISCOVERY_PATH, approvedIssuer).href, {
|
|
186
|
+
headers: { accept: "application/json" },
|
|
187
|
+
// Discovery must never follow a redirect to an unverified host.
|
|
188
|
+
redirect: "error",
|
|
189
|
+
...(signal !== undefined ? { signal } : {}),
|
|
190
|
+
});
|
|
191
|
+
} catch {
|
|
192
|
+
throw new GrokBuildOAuthError(
|
|
193
|
+
"discovery",
|
|
194
|
+
`issuer ${approvedIssuer.href} is unreachable${codingOAuthProxyUnreachableHint()}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (!response.ok) {
|
|
198
|
+
throw new GrokBuildOAuthError(
|
|
199
|
+
"discovery",
|
|
200
|
+
`issuer ${approvedIssuer.href} discovery failed (HTTP ${response.status})`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
const declaredLength = response.headers.get("content-length");
|
|
204
|
+
if (declaredLength !== null) {
|
|
205
|
+
const parsedLength = Number(declaredLength);
|
|
206
|
+
if (Number.isFinite(parsedLength) && parsedLength > DISCOVERY_MAX_BYTES) {
|
|
207
|
+
await response.body?.cancel().catch(() => undefined);
|
|
208
|
+
throw new GrokBuildOAuthError(
|
|
209
|
+
"discovery",
|
|
210
|
+
`issuer ${approvedIssuer.href} discovery body exceeds ${DISCOVERY_MAX_BYTES} bytes`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const reader = response.body?.getReader();
|
|
215
|
+
if (reader === undefined) {
|
|
216
|
+
throw new GrokBuildOAuthError("discovery", `issuer ${approvedIssuer.href} discovery returned no body`);
|
|
217
|
+
}
|
|
218
|
+
const chunks: Uint8Array[] = [];
|
|
219
|
+
let total = 0;
|
|
220
|
+
try {
|
|
221
|
+
while (true) {
|
|
222
|
+
const { value, done } = await reader.read();
|
|
223
|
+
if (done) break;
|
|
224
|
+
if (value === undefined) continue;
|
|
225
|
+
total += value.byteLength;
|
|
226
|
+
if (total > DISCOVERY_MAX_BYTES) {
|
|
227
|
+
await reader.cancel().catch(() => undefined);
|
|
228
|
+
throw new GrokBuildOAuthError(
|
|
229
|
+
"discovery",
|
|
230
|
+
`issuer ${approvedIssuer.href} discovery body exceeds ${DISCOVERY_MAX_BYTES} bytes`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
chunks.push(value);
|
|
234
|
+
}
|
|
235
|
+
} finally {
|
|
236
|
+
reader.releaseLock();
|
|
237
|
+
}
|
|
238
|
+
const text = new TextDecoder("utf-8", { fatal: false }).decode(Buffer.concat(chunks, total));
|
|
239
|
+
let body: unknown;
|
|
240
|
+
try {
|
|
241
|
+
body = JSON.parse(text);
|
|
242
|
+
} catch {
|
|
243
|
+
throw new GrokBuildOAuthError("discovery", `issuer ${approvedIssuer.href} discovery returned invalid JSON`);
|
|
244
|
+
}
|
|
245
|
+
const document = body as Partial<DiscoveryDocument>;
|
|
246
|
+
if (typeof document.issuer !== "string") {
|
|
247
|
+
throw new GrokBuildOAuthError("discovery", `issuer ${approvedIssuer.href} discovery lacks its issuer identity`);
|
|
248
|
+
}
|
|
249
|
+
const discoveredIssuer = assertApprovedIssuer(document.issuer, options);
|
|
250
|
+
if (discoveredIssuer.href !== approvedIssuer.href) {
|
|
251
|
+
throw new GrokBuildOAuthError("discovery", "discovery issuer identity does not match the configured issuer");
|
|
252
|
+
}
|
|
253
|
+
if (typeof document.authorization_endpoint !== "string" || typeof document.token_endpoint !== "string") {
|
|
254
|
+
throw new GrokBuildOAuthError("discovery", `issuer ${approvedIssuer.href} discovery lacks OAuth endpoints`);
|
|
255
|
+
}
|
|
256
|
+
const authorizeUrl = assertApprovedEndpointUrl(
|
|
257
|
+
document.authorization_endpoint,
|
|
258
|
+
"authorization_endpoint",
|
|
259
|
+
approvedIssuer,
|
|
260
|
+
options,
|
|
261
|
+
);
|
|
262
|
+
const tokenUrl = assertApprovedEndpointUrl(document.token_endpoint, "token_endpoint", approvedIssuer, options);
|
|
263
|
+
const parsed: DiscoveryDocument = {
|
|
264
|
+
issuer: discoveredIssuer.href,
|
|
265
|
+
authorization_endpoint: authorizeUrl.href,
|
|
266
|
+
token_endpoint: tokenUrl.href,
|
|
267
|
+
};
|
|
268
|
+
discoveryCache = { issuer: approvedIssuer.href, document: parsed, fetchedAt: Date.now() };
|
|
269
|
+
return parsed;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Generate an S256 PKCE verifier/challenge pair (Web Crypto compatible). */
|
|
273
|
+
export function generatePkce(): { verifier: string; challenge: string } {
|
|
274
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
275
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
276
|
+
return { verifier, challenge };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function randomToken(): string {
|
|
280
|
+
return randomBytes(16).toString("base64url");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Build the authorization URL for one login attempt. */
|
|
284
|
+
export function buildAuthorizeUrl(
|
|
285
|
+
endpoints: DiscoveryDocument,
|
|
286
|
+
params: GrokBuildOAuthParams,
|
|
287
|
+
redirectUri: string,
|
|
288
|
+
challenge: string,
|
|
289
|
+
state: string,
|
|
290
|
+
nonce: string,
|
|
291
|
+
): string {
|
|
292
|
+
const url = new URL(endpoints.authorization_endpoint);
|
|
293
|
+
url.searchParams.set("response_type", "code");
|
|
294
|
+
url.searchParams.set("client_id", params.clientId);
|
|
295
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
296
|
+
url.searchParams.set("scope", params.scope);
|
|
297
|
+
url.searchParams.set("code_challenge", challenge);
|
|
298
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
299
|
+
url.searchParams.set("state", state);
|
|
300
|
+
url.searchParams.set("nonce", nonce);
|
|
301
|
+
if (params.referrer !== undefined) url.searchParams.set("referrer", params.referrer);
|
|
302
|
+
return url.href;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const LOOPBACK_OK_PAGE =
|
|
306
|
+
'<!doctype html><meta charset="utf-8"><title>Grok Build</title>' +
|
|
307
|
+
'<body style="font-family:system-ui;text-align:center;padding:4rem">' +
|
|
308
|
+
"<h2>Grok Build sign-in complete</h2><p>You can close this tab and return to dsh.</p></body>";
|
|
309
|
+
|
|
310
|
+
const LOOPBACK_ERROR_PAGE =
|
|
311
|
+
'<!doctype html><meta charset="utf-8"><title>Grok Build</title>' +
|
|
312
|
+
'<body style="font-family:system-ui;text-align:center;padding:4rem">' +
|
|
313
|
+
"<h2>Sign-in failed</h2><p>State mismatch or missing code — try again in dsh.</p></body>";
|
|
314
|
+
|
|
315
|
+
interface LoopbackResult {
|
|
316
|
+
code: string;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Listen on 127.0.0.1 for the IdP redirect. Falls forward across a small port
|
|
321
|
+
* scan on EADDRINUSE. Any request path is accepted; only the query matters.
|
|
322
|
+
*/
|
|
323
|
+
async function listenForCode(
|
|
324
|
+
port: number,
|
|
325
|
+
state: string,
|
|
326
|
+
signal: AbortSignal,
|
|
327
|
+
): Promise<{ server: Server; port: number; wait: Promise<LoopbackResult>; dispose: () => void }> {
|
|
328
|
+
let lastError: unknown;
|
|
329
|
+
for (let attempt = 0; attempt < PORT_SCAN_ATTEMPTS; attempt += 1) {
|
|
330
|
+
const candidate = port + attempt;
|
|
331
|
+
const server = createServer();
|
|
332
|
+
// Captured by the Promise executor below; declared up-front so the closure
|
|
333
|
+
// sees a single `let` binding rather than the per-iteration TDZ version.
|
|
334
|
+
let teardown: (() => void) | undefined;
|
|
335
|
+
const wait = new Promise<LoopbackResult>((resolvePromise, rejectPromise) => {
|
|
336
|
+
server.on("request", (request, response) => {
|
|
337
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
338
|
+
const error = url.searchParams.get("error");
|
|
339
|
+
const code = url.searchParams.get("code");
|
|
340
|
+
const returnedState = url.searchParams.get("state");
|
|
341
|
+
if (error !== null) {
|
|
342
|
+
response.writeHead(400, { "content-type": "text/html" }).end(LOOPBACK_ERROR_PAGE);
|
|
343
|
+
rejectPromise(new GrokBuildOAuthError("token_exchange", `authorization returned error: ${error}`));
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
if (code === null || returnedState !== state) {
|
|
347
|
+
response.writeHead(400, { "content-type": "text/html" }).end(LOOPBACK_ERROR_PAGE);
|
|
348
|
+
rejectPromise(new GrokBuildOAuthError("state_mismatch", "loopback redirect carried a mismatched state"));
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
response.writeHead(200, { "content-type": "text/html" }).end(LOOPBACK_OK_PAGE);
|
|
352
|
+
resolvePromise({ code });
|
|
353
|
+
});
|
|
354
|
+
server.on("error", (error) => rejectPromise(error));
|
|
355
|
+
const onAbort = (): void => {
|
|
356
|
+
// Plain error on purpose: the caller maps aborts to cancelled/timeout.
|
|
357
|
+
rejectPromise(new Error("loopback listener aborted"));
|
|
358
|
+
};
|
|
359
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
360
|
+
// Hand a single teardown to the caller so the abort listener is always removed
|
|
361
|
+
// before the server is closed — otherwise `{ once: true }` leaves an orphan
|
|
362
|
+
// on the signal for the lifetime of the parent flow (and EADDRINUSE retries
|
|
363
|
+
// could pile up several before the loop exits).
|
|
364
|
+
teardown = () => {
|
|
365
|
+
signal.removeEventListener("abort", onAbort);
|
|
366
|
+
};
|
|
367
|
+
});
|
|
368
|
+
try {
|
|
369
|
+
await new Promise<void>((resolvePromise, rejectPromise) => {
|
|
370
|
+
server.once("error", rejectPromise);
|
|
371
|
+
server.listen(candidate, "127.0.0.1", resolvePromise);
|
|
372
|
+
});
|
|
373
|
+
// Swallow late errors after a successful settle of the wait promise.
|
|
374
|
+
wait.catch(() => {});
|
|
375
|
+
const address = server.address();
|
|
376
|
+
const boundPort = typeof address === "object" && address !== null ? address.port : candidate;
|
|
377
|
+
// Move the teardown to the returned bundle so the caller drops it after `server.close()`.
|
|
378
|
+
const cleanup = teardown;
|
|
379
|
+
teardown = undefined;
|
|
380
|
+
return {
|
|
381
|
+
server,
|
|
382
|
+
port: boundPort,
|
|
383
|
+
wait,
|
|
384
|
+
dispose: () => cleanup?.(),
|
|
385
|
+
};
|
|
386
|
+
} catch (error) {
|
|
387
|
+
teardown?.();
|
|
388
|
+
lastError = error;
|
|
389
|
+
server.removeAllListeners();
|
|
390
|
+
await new Promise<void>((resolvePromise) => server.close(() => resolvePromise()));
|
|
391
|
+
if ((error as NodeJS.ErrnoException)?.code !== "EADDRINUSE") break;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
throw new GrokBuildOAuthError(
|
|
395
|
+
"loopback",
|
|
396
|
+
`could not bind a loopback listener near port ${port}: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
interface TokenResponse {
|
|
401
|
+
access_token?: unknown;
|
|
402
|
+
refresh_token?: unknown;
|
|
403
|
+
expires_in?: unknown;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function credentialFromTokenResponse(body: TokenResponse, previousRefresh?: string): OAuthCredential {
|
|
407
|
+
const access = body.access_token;
|
|
408
|
+
if (typeof access !== "string" || access.length === 0) {
|
|
409
|
+
throw new GrokBuildOAuthError("token_exchange", "token response missing access_token");
|
|
410
|
+
}
|
|
411
|
+
// xAI may omit refresh_token when the token is not rotated.
|
|
412
|
+
const refresh =
|
|
413
|
+
typeof body.refresh_token === "string" && body.refresh_token.length > 0 ? body.refresh_token : previousRefresh;
|
|
414
|
+
if (refresh === undefined) {
|
|
415
|
+
throw new GrokBuildOAuthError("token_exchange", "token response missing refresh_token");
|
|
416
|
+
}
|
|
417
|
+
const expiresIn =
|
|
418
|
+
typeof body.expires_in === "number" && Number.isFinite(body.expires_in) && body.expires_in > 0
|
|
419
|
+
? body.expires_in
|
|
420
|
+
: 3600;
|
|
421
|
+
return { type: "oauth", access, refresh, expires: Date.now() + expiresIn * 1000 };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function readTokenResponseText(response: Response): Promise<string> {
|
|
425
|
+
const declaredLength = response.headers.get("content-length");
|
|
426
|
+
if (declaredLength !== null) {
|
|
427
|
+
const parsedLength = Number(declaredLength);
|
|
428
|
+
if (Number.isFinite(parsedLength) && parsedLength > TOKEN_RESPONSE_MAX_BYTES) {
|
|
429
|
+
await response.body?.cancel().catch(() => undefined);
|
|
430
|
+
throw new GrokBuildOAuthError("token_exchange", "token endpoint response exceeded the size limit");
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const reader = response.body?.getReader();
|
|
434
|
+
if (reader === undefined) throw new GrokBuildOAuthError("token_exchange", "token endpoint returned no body");
|
|
435
|
+
const chunks: Uint8Array[] = [];
|
|
436
|
+
let total = 0;
|
|
437
|
+
try {
|
|
438
|
+
while (true) {
|
|
439
|
+
const { value, done } = await reader.read();
|
|
440
|
+
if (done) break;
|
|
441
|
+
if (value === undefined) continue;
|
|
442
|
+
total += value.byteLength;
|
|
443
|
+
if (total > TOKEN_RESPONSE_MAX_BYTES) {
|
|
444
|
+
await reader.cancel().catch(() => undefined);
|
|
445
|
+
throw new GrokBuildOAuthError("token_exchange", "token endpoint response exceeded the size limit");
|
|
446
|
+
}
|
|
447
|
+
chunks.push(value);
|
|
448
|
+
}
|
|
449
|
+
} finally {
|
|
450
|
+
reader.releaseLock();
|
|
451
|
+
}
|
|
452
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(Buffer.concat(chunks, total));
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function postTokenForm(
|
|
456
|
+
tokenEndpoint: string,
|
|
457
|
+
fields: Record<string, string>,
|
|
458
|
+
signal?: AbortSignal,
|
|
459
|
+
previousRefresh?: string,
|
|
460
|
+
): Promise<OAuthCredential> {
|
|
461
|
+
let response: Response;
|
|
462
|
+
try {
|
|
463
|
+
response = await fetch(tokenEndpoint, {
|
|
464
|
+
method: "POST",
|
|
465
|
+
headers: {
|
|
466
|
+
accept: "application/json",
|
|
467
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
468
|
+
},
|
|
469
|
+
body: new URLSearchParams(fields),
|
|
470
|
+
...(signal !== undefined ? { signal } : {}),
|
|
471
|
+
});
|
|
472
|
+
} catch (error) {
|
|
473
|
+
if (signal?.aborted) throw new GrokBuildOAuthError("cancelled", "request was cancelled");
|
|
474
|
+
throw new GrokBuildOAuthError(
|
|
475
|
+
"token_exchange",
|
|
476
|
+
`token endpoint is unreachable: ${safeMessage(error)}${codingOAuthProxyUnreachableHint()}`,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
let body: TokenResponse & { error?: unknown; error_description?: unknown };
|
|
480
|
+
try {
|
|
481
|
+
body = JSON.parse(await readTokenResponseText(response)) as TokenResponse;
|
|
482
|
+
} catch (error) {
|
|
483
|
+
if (error instanceof GrokBuildOAuthError) throw error;
|
|
484
|
+
throw new GrokBuildOAuthError("token_exchange", `token endpoint returned invalid JSON (HTTP ${response.status})`);
|
|
485
|
+
}
|
|
486
|
+
if (!response.ok) {
|
|
487
|
+
const code = typeof body.error === "string" ? safeMessage(body.error) : `HTTP ${response.status}`;
|
|
488
|
+
const detail = typeof body.error_description === "string" ? `: ${safeMessage(body.error_description)}` : "";
|
|
489
|
+
throw new GrokBuildOAuthError("token_exchange", `token endpoint rejected the request (${code})${detail}`);
|
|
490
|
+
}
|
|
491
|
+
return credentialFromTokenResponse(body, previousRefresh);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Exchange a refresh token for a fresh credential (rotation-tolerant). */
|
|
495
|
+
export async function refreshGrokBuildToken(
|
|
496
|
+
refreshToken: string,
|
|
497
|
+
overrides: Partial<GrokBuildOAuthParams> = {},
|
|
498
|
+
signal?: AbortSignal,
|
|
499
|
+
discoveryOptions: DiscoveryFetchOptions = {},
|
|
500
|
+
): Promise<OAuthCredential> {
|
|
501
|
+
const params = resolveOAuthParams(overrides);
|
|
502
|
+
const endpoints = await discoverOAuthEndpoints(params.issuer, signal, discoveryOptions);
|
|
503
|
+
return postTokenForm(
|
|
504
|
+
endpoints.token_endpoint,
|
|
505
|
+
{
|
|
506
|
+
grant_type: "refresh_token",
|
|
507
|
+
client_id: params.clientId,
|
|
508
|
+
refresh_token: refreshToken,
|
|
509
|
+
},
|
|
510
|
+
signal,
|
|
511
|
+
refreshToken,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export interface PkceLoginCallbacks {
|
|
516
|
+
/** Invoked with the authorization URL to display/open for the user. */
|
|
517
|
+
onAuthorizeUrl(url: string): void;
|
|
518
|
+
/**
|
|
519
|
+
* Manual-paste channel: resolve with the code (or full redirect URL) the
|
|
520
|
+
* user pasted. Return undefined to disable this channel. Rejects on cancel.
|
|
521
|
+
*/
|
|
522
|
+
awaitCode?: (signal: AbortSignal) => Promise<string | undefined>;
|
|
523
|
+
signal?: AbortSignal;
|
|
524
|
+
timeoutMs?: number;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Extract a bare code from user input that may be a full redirect URL. */
|
|
528
|
+
export function extractCode(input: string): string {
|
|
529
|
+
const trimmed = input.trim();
|
|
530
|
+
if (trimmed.length === 0) return trimmed;
|
|
531
|
+
let candidate: string;
|
|
532
|
+
try {
|
|
533
|
+
const url = new URL(trimmed);
|
|
534
|
+
candidate = url.searchParams.get("code") ?? trimmed;
|
|
535
|
+
} catch {
|
|
536
|
+
candidate = trimmed;
|
|
537
|
+
}
|
|
538
|
+
if (candidate.length > EXTRACTED_CODE_MAX_LENGTH) {
|
|
539
|
+
throw new GrokBuildOAuthError(
|
|
540
|
+
"token_exchange",
|
|
541
|
+
`pasted authorization code exceeds ${EXTRACTED_CODE_MAX_LENGTH} characters`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
return candidate;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Run the authorization-code + PKCE login. The code arrives via the loopback
|
|
549
|
+
* listener or the manual-paste channel, whichever wins. The caller persists
|
|
550
|
+
* the returned credential (store.modify under the file lock).
|
|
551
|
+
*/
|
|
552
|
+
export async function loginGrokBuildPkce(
|
|
553
|
+
callbacks: PkceLoginCallbacks,
|
|
554
|
+
overrides: Partial<GrokBuildOAuthParams & DiscoveryFetchOptions> = {},
|
|
555
|
+
): Promise<OAuthCredential> {
|
|
556
|
+
const { allowInsecureLoopbackIssuer, ...oauthOverrides } = overrides;
|
|
557
|
+
const params = resolveOAuthParams(oauthOverrides);
|
|
558
|
+
const timeoutMs = callbacks.timeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS;
|
|
559
|
+
const controller = new AbortController();
|
|
560
|
+
/** Aborts the losing code-capture channel once one channel wins. */
|
|
561
|
+
const channelsController = new AbortController();
|
|
562
|
+
const onParentAbort = (): void => {
|
|
563
|
+
controller.abort();
|
|
564
|
+
};
|
|
565
|
+
callbacks.signal?.addEventListener("abort", onParentAbort, { once: true });
|
|
566
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
567
|
+
timer.unref?.();
|
|
568
|
+
try {
|
|
569
|
+
const endpoints = await discoverOAuthEndpoints(params.issuer, controller.signal, {
|
|
570
|
+
...(allowInsecureLoopbackIssuer === undefined ? {} : { allowInsecureLoopbackIssuer }),
|
|
571
|
+
});
|
|
572
|
+
const { verifier, challenge } = generatePkce();
|
|
573
|
+
const state = randomToken();
|
|
574
|
+
const nonce = randomToken();
|
|
575
|
+
const listener = await listenForCode(params.port, state, controller.signal);
|
|
576
|
+
const redirectUri = `http://127.0.0.1:${listener.port}/callback`;
|
|
577
|
+
const url = buildAuthorizeUrl(endpoints, params, redirectUri, challenge, state, nonce);
|
|
578
|
+
try {
|
|
579
|
+
callbacks.onAuthorizeUrl(url);
|
|
580
|
+
const channels: Promise<string>[] = [listener.wait.then((result) => result.code)];
|
|
581
|
+
if (callbacks.awaitCode !== undefined) {
|
|
582
|
+
channels.push(
|
|
583
|
+
callbacks.awaitCode(channelsController.signal).then((input) =>
|
|
584
|
+
input === undefined || extractCode(input).length === 0
|
|
585
|
+
? new Promise<string>(() => {}) // channel disabled: never settles
|
|
586
|
+
: extractCode(input),
|
|
587
|
+
),
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
const code = await Promise.race(channels);
|
|
591
|
+
channelsController.abort(); // release the losing channel (e.g. a pending paste prompt)
|
|
592
|
+
return await postTokenForm(
|
|
593
|
+
endpoints.token_endpoint,
|
|
594
|
+
{
|
|
595
|
+
grant_type: "authorization_code",
|
|
596
|
+
client_id: params.clientId,
|
|
597
|
+
redirect_uri: redirectUri,
|
|
598
|
+
code,
|
|
599
|
+
code_verifier: verifier,
|
|
600
|
+
},
|
|
601
|
+
controller.signal,
|
|
602
|
+
);
|
|
603
|
+
} finally {
|
|
604
|
+
listener.server.close();
|
|
605
|
+
listener.dispose();
|
|
606
|
+
}
|
|
607
|
+
} catch (error) {
|
|
608
|
+
if (controller.signal.aborted && !(error instanceof GrokBuildOAuthError)) {
|
|
609
|
+
const timedOut = !callbacks.signal?.aborted;
|
|
610
|
+
throw new GrokBuildOAuthError(
|
|
611
|
+
timedOut ? "timeout" : "cancelled",
|
|
612
|
+
timedOut ? `no authorization completed within ${Math.round(timeoutMs / 60000)} minutes` : "login was cancelled",
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
throw error;
|
|
616
|
+
} finally {
|
|
617
|
+
clearTimeout(timer);
|
|
618
|
+
callbacks.signal?.removeEventListener("abort", onParentAbort);
|
|
619
|
+
}
|
|
620
|
+
}
|