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
|
@@ -0,0 +1,1104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Allowlisted CLI OAuth source discovery, hardened reads, exact parsers, and
|
|
3
|
+
* two-phase preview/commit primitives. The HTTP routes and destination stores
|
|
4
|
+
* stay outside this module so the parent can wire them later.
|
|
5
|
+
* @module dsh-coding-subscription-oauth/oauth-sources
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
9
|
+
import { constants, type Stats } from "node:fs";
|
|
10
|
+
import { lstat, open } from "node:fs/promises";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join, posix, resolve } from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
CLAUDE_PI_PROVIDER,
|
|
15
|
+
CODEX_PI_PROVIDER,
|
|
16
|
+
type CodingOAuthProviderSlug,
|
|
17
|
+
KIMI_PI_PROVIDER,
|
|
18
|
+
XAI_PI_PROVIDER,
|
|
19
|
+
} from "./ids.ts";
|
|
20
|
+
|
|
21
|
+
/** Hard ceiling for a CLI source or destination document. */
|
|
22
|
+
export const OAUTH_SOURCE_MAX_BYTES = 64 * 1024;
|
|
23
|
+
|
|
24
|
+
/** In-memory preview tickets are one-use and live five minutes. */
|
|
25
|
+
export const OAUTH_IMPORT_PREVIEW_TTL_MS = 5 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
/** Bound credential-bearing preview material retained by one process. */
|
|
28
|
+
export const OAUTH_IMPORT_MAX_PREVIEW_TICKETS = 32;
|
|
29
|
+
|
|
30
|
+
const EXPIRING_SOON_MS = 60 * 60 * 1000;
|
|
31
|
+
const PREVIEW_ID_BYTES = 18;
|
|
32
|
+
const HMAC_KEY_BYTES = 32;
|
|
33
|
+
const OPENAI_AUTH_CLAIM = "https://api.openai.com/auth";
|
|
34
|
+
|
|
35
|
+
/** Approved Grok OIDC issuer host. Only documents scoped to this exact host are accepted. */
|
|
36
|
+
const APPROVED_GROK_ISSUER_HOST = "auth.x.ai";
|
|
37
|
+
const APPROVED_GROK_ISSUER_ORIGIN = `https://${APPROVED_GROK_ISSUER_HOST}`;
|
|
38
|
+
|
|
39
|
+
/** True when `value` is a host or origin that exactly names the approved Grok issuer. */
|
|
40
|
+
function isApprovedGrokIssuerValue(value: string): boolean {
|
|
41
|
+
if (value === APPROVED_GROK_ISSUER_HOST || value === APPROVED_GROK_ISSUER_ORIGIN) return true;
|
|
42
|
+
// Scope-map keys follow the `<origin>::<clientId>` convention. Accept the
|
|
43
|
+
// prefix so `https://auth.x.ai::b1a00492-…` matches even though it is not a
|
|
44
|
+
// parseable URL.
|
|
45
|
+
if (value.startsWith(`${APPROVED_GROK_ISSUER_ORIGIN}::`)) {
|
|
46
|
+
const clientId = value.slice(APPROVED_GROK_ISSUER_ORIGIN.length + 2);
|
|
47
|
+
return /^[A-Za-z0-9._-]{1,128}$/u.test(clientId);
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const parsed = new URL(value);
|
|
51
|
+
if (parsed.origin !== APPROVED_GROK_ISSUER_ORIGIN) return false;
|
|
52
|
+
if (
|
|
53
|
+
parsed.username !== "" ||
|
|
54
|
+
parsed.password !== "" ||
|
|
55
|
+
parsed.pathname !== "/" ||
|
|
56
|
+
parsed.search !== "" ||
|
|
57
|
+
parsed.hash !== ""
|
|
58
|
+
) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type OAuthSourceKind = CodingOAuthProviderSlug;
|
|
68
|
+
|
|
69
|
+
export const OAUTH_SOURCE_KINDS = ["grok", "codex", "kimi", "claude"] as const satisfies readonly OAuthSourceKind[];
|
|
70
|
+
|
|
71
|
+
export type OAuthImportConflict =
|
|
72
|
+
| "none"
|
|
73
|
+
| "same_credential"
|
|
74
|
+
| "same_account"
|
|
75
|
+
| "different_account"
|
|
76
|
+
| "unknown_account"
|
|
77
|
+
| "unreadable_destination"
|
|
78
|
+
| "unsafe_destination";
|
|
79
|
+
|
|
80
|
+
export type OAuthImportPreviewAction = "import" | "reuse" | "overwrite" | "blocked";
|
|
81
|
+
export type OAuthImportCommitAction = "imported" | "unchanged" | "overwritten";
|
|
82
|
+
|
|
83
|
+
export type OAuthSourceErrorCode =
|
|
84
|
+
| "not_found"
|
|
85
|
+
| "unsafe_source"
|
|
86
|
+
| "too_large"
|
|
87
|
+
| "invalid_document"
|
|
88
|
+
| "unsupported"
|
|
89
|
+
| "preview_expired"
|
|
90
|
+
| "preview_invalid"
|
|
91
|
+
| "source_changed"
|
|
92
|
+
| "destination_changed"
|
|
93
|
+
| "confirm_required"
|
|
94
|
+
| "unsafe_destination";
|
|
95
|
+
|
|
96
|
+
export type OAuthSourceUnavailableReason = "missing" | "unsafe" | "invalid" | "too_large";
|
|
97
|
+
export type OAuthDestinationStatus = "missing" | "readable" | "unreadable" | "unsafe";
|
|
98
|
+
|
|
99
|
+
export interface OAuthSourceCredential {
|
|
100
|
+
type: "oauth";
|
|
101
|
+
access: string;
|
|
102
|
+
refresh: string;
|
|
103
|
+
expires: number;
|
|
104
|
+
accountId?: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface OAuthSourceSpec {
|
|
108
|
+
kind: OAuthSourceKind;
|
|
109
|
+
envHome: "GROK_HOME" | "CODEX_HOME" | "KIMI_SHARE_DIR" | "CLAUDE_CONFIG_DIR";
|
|
110
|
+
defaultDir: string;
|
|
111
|
+
relativeFile: string;
|
|
112
|
+
providerId: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface OAuthSourcePathOptions {
|
|
116
|
+
env?: NodeJS.Dict<string>;
|
|
117
|
+
home?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface OAuthSourceFileIdentity {
|
|
121
|
+
dev: number;
|
|
122
|
+
ino: number;
|
|
123
|
+
size: number;
|
|
124
|
+
uid: number;
|
|
125
|
+
mode: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface HardenedOAuthSourceRead {
|
|
129
|
+
path: string;
|
|
130
|
+
text: string;
|
|
131
|
+
identity: OAuthSourceFileIdentity;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface OAuthSourceDiscovery {
|
|
135
|
+
kind: OAuthSourceKind;
|
|
136
|
+
displayPath: string;
|
|
137
|
+
available: boolean;
|
|
138
|
+
expiresAt?: number;
|
|
139
|
+
reason?: OAuthSourceUnavailableReason;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface OAuthSourceProbe {
|
|
143
|
+
kind: OAuthSourceKind;
|
|
144
|
+
available: boolean;
|
|
145
|
+
displayPath: string;
|
|
146
|
+
expiresAt?: number;
|
|
147
|
+
reason?: OAuthSourceUnavailableReason;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface OAuthImportDestinationView {
|
|
151
|
+
path?: string;
|
|
152
|
+
status?: OAuthDestinationStatus;
|
|
153
|
+
credential?: OAuthSourceCredential;
|
|
154
|
+
revision?: string;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface OAuthDestinationInspection {
|
|
158
|
+
status: OAuthDestinationStatus;
|
|
159
|
+
identity?: OAuthSourceFileIdentity;
|
|
160
|
+
/** Internal. Never serialize this field to a client. */
|
|
161
|
+
credential?: OAuthSourceCredential;
|
|
162
|
+
/** Internal digest of unreadable dest bytes. Never serialize this field to a client. */
|
|
163
|
+
payloadMac?: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface OAuthImportPreview {
|
|
167
|
+
previewId: string;
|
|
168
|
+
kind: OAuthSourceKind;
|
|
169
|
+
displayPath: string;
|
|
170
|
+
expiresAt: number;
|
|
171
|
+
ticketExpiresAt: number;
|
|
172
|
+
conflict: OAuthImportConflict;
|
|
173
|
+
action: OAuthImportPreviewAction;
|
|
174
|
+
warnings: string[];
|
|
175
|
+
confirmOverwriteRequired: boolean;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface OAuthImportCommitResult {
|
|
179
|
+
action: OAuthImportCommitAction;
|
|
180
|
+
displayPath: string;
|
|
181
|
+
expiresAt: number;
|
|
182
|
+
warnings: string[];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface OAuthImportSessionOptions {
|
|
186
|
+
now?: () => number;
|
|
187
|
+
ttlMs?: number;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface OAuthImportPreviewInput extends OAuthSourcePathOptions {
|
|
191
|
+
kind: OAuthSourceKind;
|
|
192
|
+
destination?: OAuthImportDestinationView;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface OAuthImportCommitInput extends OAuthSourcePathOptions {
|
|
196
|
+
previewId: string;
|
|
197
|
+
/** When set, must match the ticket kind; a mismatch does not consume the ticket. */
|
|
198
|
+
kind?: OAuthSourceKind;
|
|
199
|
+
confirmOverwrite?: boolean;
|
|
200
|
+
destination?: OAuthImportDestinationView;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Secret-free preview claim used to route a ticket to its destination store. */
|
|
204
|
+
export interface OAuthImportPreviewClaim {
|
|
205
|
+
kind: OAuthSourceKind;
|
|
206
|
+
ticketExpiresAt: number;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
interface Ticket {
|
|
210
|
+
previewId: string;
|
|
211
|
+
kind: OAuthSourceKind;
|
|
212
|
+
sourcePath: string;
|
|
213
|
+
displayPath: string;
|
|
214
|
+
sourceIdentity: OAuthSourceFileIdentity;
|
|
215
|
+
destinationRevision: string;
|
|
216
|
+
destinationFingerprint: string;
|
|
217
|
+
credential: OAuthSourceCredential;
|
|
218
|
+
conflict: OAuthImportConflict;
|
|
219
|
+
createdAt: number;
|
|
220
|
+
ticketExpiresAt: number;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const SOURCE_SPECS: { [K in OAuthSourceKind]: OAuthSourceSpec } = {
|
|
224
|
+
grok: {
|
|
225
|
+
kind: "grok",
|
|
226
|
+
envHome: "GROK_HOME",
|
|
227
|
+
defaultDir: ".grok",
|
|
228
|
+
relativeFile: "auth.json",
|
|
229
|
+
providerId: XAI_PI_PROVIDER,
|
|
230
|
+
},
|
|
231
|
+
codex: {
|
|
232
|
+
kind: "codex",
|
|
233
|
+
envHome: "CODEX_HOME",
|
|
234
|
+
defaultDir: ".codex",
|
|
235
|
+
relativeFile: "auth.json",
|
|
236
|
+
providerId: CODEX_PI_PROVIDER,
|
|
237
|
+
},
|
|
238
|
+
kimi: {
|
|
239
|
+
kind: "kimi",
|
|
240
|
+
envHome: "KIMI_SHARE_DIR",
|
|
241
|
+
defaultDir: ".kimi",
|
|
242
|
+
relativeFile: posix.join("credentials", "kimi-code.json"),
|
|
243
|
+
providerId: KIMI_PI_PROVIDER,
|
|
244
|
+
},
|
|
245
|
+
claude: {
|
|
246
|
+
kind: "claude",
|
|
247
|
+
envHome: "CLAUDE_CONFIG_DIR",
|
|
248
|
+
defaultDir: ".claude",
|
|
249
|
+
relativeFile: ".credentials.json",
|
|
250
|
+
providerId: CLAUDE_PI_PROVIDER,
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/** Stable allowlist metadata for parent route/store wiring. */
|
|
255
|
+
export const OAUTH_SOURCE_SPECS: readonly OAuthSourceSpec[] = OAUTH_SOURCE_KINDS.map((kind) => SOURCE_SPECS[kind]);
|
|
256
|
+
|
|
257
|
+
/** Secret-free failure from discovery, parse, preview, or commit. */
|
|
258
|
+
export class OAuthSourceError extends Error {
|
|
259
|
+
readonly code: OAuthSourceErrorCode;
|
|
260
|
+
|
|
261
|
+
constructor(code: OAuthSourceErrorCode, message: string) {
|
|
262
|
+
super(message);
|
|
263
|
+
this.name = "OAuthSourceError";
|
|
264
|
+
this.code = code;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function isOAuthSourceError(error: unknown): error is OAuthSourceError {
|
|
269
|
+
return error instanceof OAuthSourceError;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function isOAuthSourceKind(value: string): value is OAuthSourceKind {
|
|
273
|
+
return (OAUTH_SOURCE_KINDS as readonly string[]).includes(value);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function oauthSourceSpec(kind: OAuthSourceKind): OAuthSourceSpec {
|
|
277
|
+
return SOURCE_SPECS[kind];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function oauthSourceProviderId(kind: OAuthSourceKind): string {
|
|
281
|
+
return SOURCE_SPECS[kind].providerId;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Resolve one allowlisted CLI auth document. Env/home are injectable for tests. */
|
|
285
|
+
export function resolveOAuthSourcePath(kind: OAuthSourceKind, options: OAuthSourcePathOptions = {}): string {
|
|
286
|
+
const spec = SOURCE_SPECS[kind];
|
|
287
|
+
const env = options.env ?? process.env;
|
|
288
|
+
const home = options.home ?? nonEmptyString(env["HOME"]) ?? homedir();
|
|
289
|
+
const override = nonEmptyString(env[spec.envHome]);
|
|
290
|
+
const base = override ?? join(home, spec.defaultDir);
|
|
291
|
+
return resolve(base, spec.relativeFile);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Client-safe display path. Never returns an absolute filesystem location.
|
|
296
|
+
* Env overrides render as `$GROK_HOME/auth.json`; defaults as `~/.grok/auth.json`.
|
|
297
|
+
*/
|
|
298
|
+
export function oauthSourceDisplayPath(kind: OAuthSourceKind, options: OAuthSourcePathOptions = {}): string {
|
|
299
|
+
const spec = SOURCE_SPECS[kind];
|
|
300
|
+
const env = options.env ?? process.env;
|
|
301
|
+
if (nonEmptyString(env[spec.envHome]) !== undefined) {
|
|
302
|
+
return `$${spec.envHome}/${spec.relativeFile}`;
|
|
303
|
+
}
|
|
304
|
+
return `~/${posix.join(spec.defaultDir, spec.relativeFile)}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function oauthImportRequiresConfirm(conflict: OAuthImportConflict): boolean {
|
|
308
|
+
return conflict !== "none" && conflict !== "same_credential" && conflict !== "unsafe_destination";
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function oauthImportPreviewAction(conflict: OAuthImportConflict): OAuthImportPreviewAction {
|
|
312
|
+
if (conflict === "none") return "import";
|
|
313
|
+
if (conflict === "same_credential") return "reuse";
|
|
314
|
+
if (conflict === "unsafe_destination") return "blocked";
|
|
315
|
+
return "overwrite";
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* lstat → O_NOFOLLOW open → fstat. Rejects symlinks, non-regular files, non-owner
|
|
320
|
+
* files, group/other access, TOCTOU identity changes, and payloads over 64KiB.
|
|
321
|
+
* POSIX fails closed if O_NOFOLLOW or the process uid is unavailable. Windows
|
|
322
|
+
* skips POSIX uid/mode owner checks but still applies identity and size limits.
|
|
323
|
+
* Never chmod()s or writes the path.
|
|
324
|
+
*/
|
|
325
|
+
export async function readHardenedOAuthSourceFile(filename: string): Promise<HardenedOAuthSourceRead> {
|
|
326
|
+
const path = resolve(filename);
|
|
327
|
+
let listed: Stats;
|
|
328
|
+
try {
|
|
329
|
+
listed = await lstat(path);
|
|
330
|
+
} catch (error) {
|
|
331
|
+
throw mapReadError(error, "source");
|
|
332
|
+
}
|
|
333
|
+
assertSafeStats(listed, "source", true);
|
|
334
|
+
|
|
335
|
+
const flags = openFlags();
|
|
336
|
+
let handle: Awaited<ReturnType<typeof open>> | undefined;
|
|
337
|
+
try {
|
|
338
|
+
handle = await open(path, flags);
|
|
339
|
+
const opened = await handle.stat();
|
|
340
|
+
assertSafeStats(opened, "source", false);
|
|
341
|
+
assertSameIdentity(listed, opened);
|
|
342
|
+
if (opened.size > OAUTH_SOURCE_MAX_BYTES) {
|
|
343
|
+
throw new OAuthSourceError("too_large", "oauth source: file exceeds the 64KiB limit");
|
|
344
|
+
}
|
|
345
|
+
const buffer = await handle.readFile();
|
|
346
|
+
if (buffer.byteLength > OAUTH_SOURCE_MAX_BYTES) {
|
|
347
|
+
throw new OAuthSourceError("too_large", "oauth source: file exceeds the 64KiB limit");
|
|
348
|
+
}
|
|
349
|
+
const after = await handle.stat();
|
|
350
|
+
assertSafeStats(after, "source", false);
|
|
351
|
+
assertSameIdentity(opened, after);
|
|
352
|
+
if (after.size !== buffer.byteLength) {
|
|
353
|
+
throw new OAuthSourceError("unsafe_source", "oauth source: file changed while it was being read");
|
|
354
|
+
}
|
|
355
|
+
return { path, text: buffer.toString("utf8"), identity: identityOf(opened) };
|
|
356
|
+
} catch (error) {
|
|
357
|
+
if (error instanceof OAuthSourceError) throw error;
|
|
358
|
+
throw mapReadError(error, "source");
|
|
359
|
+
} finally {
|
|
360
|
+
await handle?.close().catch(() => undefined);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Inspect a destination store file with the same hardened reader. Never follows dest. */
|
|
365
|
+
export async function inspectOAuthDestinationFile(filename: string): Promise<OAuthDestinationInspection> {
|
|
366
|
+
try {
|
|
367
|
+
const read = await readHardenedOAuthSourceFile(filename);
|
|
368
|
+
const credential = parseStoredOAuthCredentialDocument(read.text);
|
|
369
|
+
if (credential === undefined) {
|
|
370
|
+
return { status: "unreadable", identity: read.identity, payloadMac: payloadMacOf(read.text) };
|
|
371
|
+
}
|
|
372
|
+
return { status: "readable", identity: read.identity, credential };
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (error instanceof OAuthSourceError && error.code === "not_found") {
|
|
375
|
+
return { status: "missing" };
|
|
376
|
+
}
|
|
377
|
+
if (error instanceof OAuthSourceError && (error.code === "unsafe_source" || error.code === "too_large")) {
|
|
378
|
+
return { status: "unsafe" };
|
|
379
|
+
}
|
|
380
|
+
return { status: "unreadable" };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function parseOAuthSourceDocument(kind: OAuthSourceKind, text: string): OAuthSourceCredential {
|
|
385
|
+
switch (kind) {
|
|
386
|
+
case "grok":
|
|
387
|
+
return parseGrokCliAuthDocument(text);
|
|
388
|
+
case "codex":
|
|
389
|
+
return parseCodexCliAuthDocument(text);
|
|
390
|
+
case "kimi":
|
|
391
|
+
return parseKimiCliAuthDocument(text);
|
|
392
|
+
case "claude":
|
|
393
|
+
return parseClaudeCliAuthDocument(text);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Grok CLI scope map. Selects the auth.x.ai OIDC entry (`key` + `refresh_token`).
|
|
399
|
+
* Expiry must be a valid RFC3339 `expires_at`. Multiple matching entries pick
|
|
400
|
+
* the latest valid expiry.
|
|
401
|
+
*/
|
|
402
|
+
export function parseGrokCliAuthDocument(text: string): OAuthSourceCredential {
|
|
403
|
+
const value = parseJsonObject(text, "grok CLI auth document");
|
|
404
|
+
const mapped: OAuthSourceCredential[] = [];
|
|
405
|
+
for (const [mapKey, nested] of Object.entries(value)) {
|
|
406
|
+
if (!isRecord(nested)) continue;
|
|
407
|
+
const parsed = grokOidcEntry(nested, mapKey);
|
|
408
|
+
if (parsed !== undefined) mapped.push(parsed);
|
|
409
|
+
}
|
|
410
|
+
const chosen = latestGrokCredential(mapped);
|
|
411
|
+
if (chosen !== undefined) return chosen;
|
|
412
|
+
const direct = grokOidcEntry(value);
|
|
413
|
+
if (direct !== undefined) return direct;
|
|
414
|
+
throw new OAuthSourceError(
|
|
415
|
+
"invalid_document",
|
|
416
|
+
"grok CLI auth document has no auth.x.ai OIDC key, refresh_token, and RFC3339 expires_at",
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Codex CLI `tokens` object. Requires id/access/refresh, JWT `exp` in seconds.
|
|
422
|
+
* Account id prefers official `tokens.account_id`, then top-level `account_id`
|
|
423
|
+
* for compatibility, then the full ChatGPT account id from the access JWT.
|
|
424
|
+
* Rejects API-key documents.
|
|
425
|
+
*/
|
|
426
|
+
export function parseCodexCliAuthDocument(text: string): OAuthSourceCredential {
|
|
427
|
+
const value = parseJsonObject(text, "codex CLI auth document");
|
|
428
|
+
if (nonEmptyString(value["OPENAI_API_KEY"]) !== undefined) {
|
|
429
|
+
throw new OAuthSourceError("invalid_document", "codex CLI auth document is an API key, not an OAuth session");
|
|
430
|
+
}
|
|
431
|
+
const mode = nonEmptyString(value["auth_mode"]);
|
|
432
|
+
if (mode === "apikey" || mode === "api_key") {
|
|
433
|
+
throw new OAuthSourceError("invalid_document", "codex CLI auth document is an API key, not an OAuth session");
|
|
434
|
+
}
|
|
435
|
+
const tokens = value["tokens"];
|
|
436
|
+
if (!isRecord(tokens)) {
|
|
437
|
+
throw new OAuthSourceError("invalid_document", "codex CLI auth document does not contain a tokens object");
|
|
438
|
+
}
|
|
439
|
+
const access = nonEmptyString(tokens["access_token"]);
|
|
440
|
+
const refresh = nonEmptyString(tokens["refresh_token"]);
|
|
441
|
+
const idToken = nonEmptyString(tokens["id_token"]);
|
|
442
|
+
if (access === undefined || refresh === undefined || idToken === undefined) {
|
|
443
|
+
throw new OAuthSourceError(
|
|
444
|
+
"invalid_document",
|
|
445
|
+
"codex CLI auth document tokens must include id_token, access_token, and refresh_token",
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
const expires = jwtExpMs(access) ?? jwtExpMs(idToken);
|
|
449
|
+
if (expires === undefined) {
|
|
450
|
+
throw new OAuthSourceError("invalid_document", "codex CLI auth document tokens do not contain a JWT exp claim");
|
|
451
|
+
}
|
|
452
|
+
const accountId =
|
|
453
|
+
nonEmptyString(tokens["account_id"]) ??
|
|
454
|
+
nonEmptyString(value["account_id"]) ??
|
|
455
|
+
chatgptAccountIdFromAccessJwt(access);
|
|
456
|
+
return credentialOf(access, refresh, expires, accountId);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Kimi Code CLI document. Snake_case `access_token` / `refresh_token` / `expires_at` seconds. */
|
|
460
|
+
export function parseKimiCliAuthDocument(text: string): OAuthSourceCredential {
|
|
461
|
+
const value = parseJsonObject(text, "kimi CLI auth document");
|
|
462
|
+
const access = nonEmptyString(value["access_token"]);
|
|
463
|
+
const refresh = nonEmptyString(value["refresh_token"]);
|
|
464
|
+
const expiresAt = value["expires_at"];
|
|
465
|
+
if (access === undefined || refresh === undefined) {
|
|
466
|
+
throw new OAuthSourceError(
|
|
467
|
+
"invalid_document",
|
|
468
|
+
"kimi CLI auth document must contain access_token and refresh_token",
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= 0) {
|
|
472
|
+
throw new OAuthSourceError("invalid_document", "kimi CLI auth document expires_at must be positive Unix seconds");
|
|
473
|
+
}
|
|
474
|
+
const accountId = nonEmptyString(value["user_id"]);
|
|
475
|
+
return credentialOf(access, refresh, expiresAt * 1000, accountId);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Claude Code CLI document. `claudeAiOauth` camelCase `accessToken` / `refreshToken` / `expiresAt` ms. */
|
|
479
|
+
export function parseClaudeCliAuthDocument(text: string): OAuthSourceCredential {
|
|
480
|
+
const value = parseJsonObject(text, "claude CLI auth document");
|
|
481
|
+
const nested = value["claudeAiOauth"];
|
|
482
|
+
if (!isRecord(nested)) {
|
|
483
|
+
throw new OAuthSourceError("invalid_document", "claude CLI auth document does not contain claudeAiOauth");
|
|
484
|
+
}
|
|
485
|
+
const access = nonEmptyString(nested["accessToken"]);
|
|
486
|
+
const refresh = nonEmptyString(nested["refreshToken"]);
|
|
487
|
+
const expiresAt = nested["expiresAt"];
|
|
488
|
+
if (access === undefined || refresh === undefined) {
|
|
489
|
+
throw new OAuthSourceError(
|
|
490
|
+
"invalid_document",
|
|
491
|
+
"claude CLI auth document claudeAiOauth must contain accessToken and refreshToken",
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
if (typeof expiresAt !== "number" || !Number.isFinite(expiresAt) || expiresAt <= 0) {
|
|
495
|
+
throw new OAuthSourceError(
|
|
496
|
+
"invalid_document",
|
|
497
|
+
"claude CLI auth document expiresAt must be a positive Unix millisecond value",
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
const accountId = nonEmptyString(nested["accountId"]) ?? nonEmptyString(nested["account_id"]);
|
|
501
|
+
return credentialOf(access, refresh, expiresAt, accountId);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export async function probeOAuthSource(
|
|
505
|
+
kind: OAuthSourceKind,
|
|
506
|
+
options: OAuthSourcePathOptions = {},
|
|
507
|
+
): Promise<OAuthSourceProbe> {
|
|
508
|
+
const displayPath = oauthSourceDisplayPath(kind, options);
|
|
509
|
+
const path = resolveOAuthSourcePath(kind, options);
|
|
510
|
+
try {
|
|
511
|
+
const read = await readHardenedOAuthSourceFile(path);
|
|
512
|
+
const credential = parseOAuthSourceDocument(kind, read.text);
|
|
513
|
+
return { kind, available: true, displayPath, expiresAt: credential.expires };
|
|
514
|
+
} catch (error) {
|
|
515
|
+
return { kind, available: false, displayPath, reason: unavailableReason(error) };
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
export async function discoverOAuthSources(options: OAuthSourcePathOptions = {}): Promise<OAuthSourceDiscovery[]> {
|
|
520
|
+
const listings: OAuthSourceDiscovery[] = [];
|
|
521
|
+
for (const kind of OAUTH_SOURCE_KINDS) {
|
|
522
|
+
listings.push(await probeOAuthSource(kind, options));
|
|
523
|
+
}
|
|
524
|
+
return listings;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* In-memory two-phase import controller. Preview IDs are random, one-use, and
|
|
529
|
+
* expire after five minutes. `peekPreview` exposes only kind + ticket expiry so
|
|
530
|
+
* routes can bind a destination without a second ticket map. HMAC fingerprints
|
|
531
|
+
* and persist material stay internal.
|
|
532
|
+
*/
|
|
533
|
+
export class OAuthImportSession {
|
|
534
|
+
readonly #tickets = new Map<string, Ticket>();
|
|
535
|
+
readonly #hmacKey = randomBytes(HMAC_KEY_BYTES);
|
|
536
|
+
readonly #now: () => number;
|
|
537
|
+
readonly #ttlMs: number;
|
|
538
|
+
|
|
539
|
+
constructor(options: OAuthImportSessionOptions = {}) {
|
|
540
|
+
this.#now = options.now ?? Date.now;
|
|
541
|
+
this.#ttlMs = options.ttlMs ?? OAUTH_IMPORT_PREVIEW_TTL_MS;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async discover(options: OAuthSourcePathOptions = {}): Promise<OAuthSourceDiscovery[]> {
|
|
545
|
+
this.#purge(this.#now());
|
|
546
|
+
return discoverOAuthSources(options);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Secret-free peek. Does not consume a live ticket. An expired ticket is
|
|
551
|
+
* deleted and reported as {@link OAuthSourceError} `preview_expired` once.
|
|
552
|
+
*/
|
|
553
|
+
peekPreview(previewId: string): OAuthImportPreviewClaim {
|
|
554
|
+
return this.#peekTicket(previewId, this.#now());
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
async preview(input: OAuthImportPreviewInput): Promise<OAuthImportPreview> {
|
|
558
|
+
if (!isOAuthSourceKind(input.kind)) {
|
|
559
|
+
throw new OAuthSourceError("unsupported", "oauth import: unknown source kind");
|
|
560
|
+
}
|
|
561
|
+
const now = this.#now();
|
|
562
|
+
this.#purge(now);
|
|
563
|
+
const sourcePath = resolveOAuthSourcePath(input.kind, input);
|
|
564
|
+
const displayPath = oauthSourceDisplayPath(input.kind, input);
|
|
565
|
+
const read = await readHardenedOAuthSourceFile(sourcePath);
|
|
566
|
+
const credential = parseOAuthSourceDocument(input.kind, read.text);
|
|
567
|
+
const destination = await this.#resolveDestination(input.destination);
|
|
568
|
+
const conflict = classifyOAuthImportConflict(credential, destination);
|
|
569
|
+
const previewId = this.#newPreviewId();
|
|
570
|
+
const ticketExpiresAt = now + this.#ttlMs;
|
|
571
|
+
const ticket: Ticket = {
|
|
572
|
+
previewId,
|
|
573
|
+
kind: input.kind,
|
|
574
|
+
sourcePath,
|
|
575
|
+
displayPath,
|
|
576
|
+
sourceIdentity: read.identity,
|
|
577
|
+
destinationRevision: this.#destinationRevision(destination),
|
|
578
|
+
destinationFingerprint: this.#destinationFingerprint(destination),
|
|
579
|
+
credential: cloneCredential(credential),
|
|
580
|
+
conflict,
|
|
581
|
+
createdAt: now,
|
|
582
|
+
ticketExpiresAt,
|
|
583
|
+
};
|
|
584
|
+
while (this.#tickets.size >= OAUTH_IMPORT_MAX_PREVIEW_TICKETS) {
|
|
585
|
+
const oldest = this.#tickets.keys().next().value;
|
|
586
|
+
if (oldest === undefined) break;
|
|
587
|
+
this.#tickets.delete(oldest);
|
|
588
|
+
}
|
|
589
|
+
this.#tickets.set(previewId, ticket);
|
|
590
|
+
return publicPreview(ticket, now);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Consume the preview, reopen the allowlisted source, and compare the
|
|
595
|
+
* destination revision. Call this from inside the destination store lock.
|
|
596
|
+
* Use {@link OAuthImportCommitOutcome.takePersist} for the write material.
|
|
597
|
+
*/
|
|
598
|
+
async commit(input: OAuthImportCommitInput): Promise<OAuthImportCommitOutcome> {
|
|
599
|
+
const now = this.#now();
|
|
600
|
+
if (input.kind !== undefined) {
|
|
601
|
+
const claim = this.#peekTicket(input.previewId, now);
|
|
602
|
+
if (claim.kind !== input.kind) {
|
|
603
|
+
throw new OAuthSourceError("unsupported", "oauth import: kind does not match the preview");
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const ticket = this.#takeTicket(input.previewId, now);
|
|
607
|
+
|
|
608
|
+
const expectedPath = resolveOAuthSourcePath(ticket.kind, input);
|
|
609
|
+
if (expectedPath !== ticket.sourcePath) {
|
|
610
|
+
throw new OAuthSourceError("source_changed", "oauth import: source path no longer matches the allowlist");
|
|
611
|
+
}
|
|
612
|
+
const read = await readHardenedOAuthSourceFile(ticket.sourcePath);
|
|
613
|
+
if (!sameIdentity(ticket.sourceIdentity, read.identity)) {
|
|
614
|
+
throw new OAuthSourceError("source_changed", "oauth import: source file identity changed");
|
|
615
|
+
}
|
|
616
|
+
const fresh = parseOAuthSourceDocument(ticket.kind, read.text);
|
|
617
|
+
if (fresh.access !== ticket.credential.access || fresh.refresh !== ticket.credential.refresh) {
|
|
618
|
+
throw new OAuthSourceError("source_changed", "oauth import: source credential changed");
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const destination = await this.#resolveDestination(input.destination);
|
|
622
|
+
if (!this.#sameDestination(ticket, destination)) {
|
|
623
|
+
throw new OAuthSourceError("destination_changed", "oauth import: destination revision changed");
|
|
624
|
+
}
|
|
625
|
+
const conflict = classifyOAuthImportConflict(fresh, destination);
|
|
626
|
+
if (conflict === "unsafe_destination") {
|
|
627
|
+
throw new OAuthSourceError("unsafe_destination", "oauth import: destination file is not safe to replace");
|
|
628
|
+
}
|
|
629
|
+
if (oauthImportRequiresConfirm(conflict) && input.confirmOverwrite !== true) {
|
|
630
|
+
throw new OAuthSourceError(
|
|
631
|
+
"confirm_required",
|
|
632
|
+
"oauth import: confirmOverwrite is required to replace the stored credential",
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const warnings = credentialWarnings(fresh, conflict, now);
|
|
637
|
+
if (conflict === "same_credential") {
|
|
638
|
+
const expiresAt = destination.credential?.expires ?? fresh.expires;
|
|
639
|
+
return new OAuthImportCommitOutcome(
|
|
640
|
+
{ action: "unchanged", displayPath: ticket.displayPath, expiresAt, warnings },
|
|
641
|
+
undefined,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
const action: OAuthImportCommitAction = conflict === "none" ? "imported" : "overwritten";
|
|
645
|
+
return new OAuthImportCommitOutcome(
|
|
646
|
+
{ action, displayPath: ticket.displayPath, expiresAt: fresh.expires, warnings },
|
|
647
|
+
cloneCredential(fresh),
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
cancel(previewId: string): boolean {
|
|
652
|
+
const existed = this.#tickets.delete(previewId);
|
|
653
|
+
this.#purge(this.#now());
|
|
654
|
+
return existed;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async #resolveDestination(view: OAuthImportDestinationView | undefined): Promise<ResolvedDestination> {
|
|
658
|
+
if (view === undefined) return { status: "missing" };
|
|
659
|
+
if (view.path !== undefined) {
|
|
660
|
+
return this.#resolvePathDestination(view.path, view.revision);
|
|
661
|
+
}
|
|
662
|
+
const extras = view.revision === undefined ? {} : { revision: view.revision };
|
|
663
|
+
if (view.status === "unsafe") {
|
|
664
|
+
return { status: "unsafe", ...extras };
|
|
665
|
+
}
|
|
666
|
+
if (view.credential !== undefined) {
|
|
667
|
+
return { status: "readable", credential: cloneCredential(view.credential), ...extras };
|
|
668
|
+
}
|
|
669
|
+
if (view.status === "unreadable") {
|
|
670
|
+
return { status: "unreadable", ...extras };
|
|
671
|
+
}
|
|
672
|
+
if (view.status === "readable") {
|
|
673
|
+
return { status: "unreadable", ...extras };
|
|
674
|
+
}
|
|
675
|
+
return { status: "missing", ...extras };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async #resolvePathDestination(path: string, revision: string | undefined): Promise<ResolvedDestination> {
|
|
679
|
+
const inspected = await inspectOAuthDestinationFile(path);
|
|
680
|
+
const extras = {
|
|
681
|
+
...(inspected.identity === undefined ? {} : { identity: inspected.identity }),
|
|
682
|
+
...(inspected.payloadMac === undefined ? {} : { payloadMac: inspected.payloadMac }),
|
|
683
|
+
...(revision === undefined ? {} : { revision }),
|
|
684
|
+
};
|
|
685
|
+
if (inspected.status === "unsafe") {
|
|
686
|
+
return { status: "unsafe", ...extras };
|
|
687
|
+
}
|
|
688
|
+
if (inspected.status === "unreadable") {
|
|
689
|
+
return { status: "unreadable", ...extras };
|
|
690
|
+
}
|
|
691
|
+
if (inspected.status === "readable" && inspected.credential !== undefined) {
|
|
692
|
+
return { status: "readable", credential: cloneCredential(inspected.credential), ...extras };
|
|
693
|
+
}
|
|
694
|
+
return { status: "missing", ...extras };
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
#destinationRevision(destination: ResolvedDestination): string {
|
|
698
|
+
if (destination.revision !== undefined && destination.revision.length > 0) {
|
|
699
|
+
return destination.revision;
|
|
700
|
+
}
|
|
701
|
+
return this.#destinationFingerprint(destination);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
#destinationFingerprint(destination: ResolvedDestination): string {
|
|
705
|
+
return hmacHex(this.#hmacKey, serializeDestination(destination));
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
#sameDestination(ticket: Ticket, destination: ResolvedDestination): boolean {
|
|
709
|
+
return (
|
|
710
|
+
sameRevision(ticket.destinationRevision, this.#destinationRevision(destination)) &&
|
|
711
|
+
sameRevision(ticket.destinationFingerprint, this.#destinationFingerprint(destination))
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
#peekTicket(previewId: string, now: number): OAuthImportPreviewClaim {
|
|
716
|
+
const ticket = this.#tickets.get(previewId);
|
|
717
|
+
if (ticket !== undefined && ticket.ticketExpiresAt <= now) {
|
|
718
|
+
this.#tickets.delete(previewId);
|
|
719
|
+
this.#purge(now);
|
|
720
|
+
throw new OAuthSourceError("preview_expired", "oauth import: preview has expired");
|
|
721
|
+
}
|
|
722
|
+
this.#purge(now);
|
|
723
|
+
if (ticket === undefined) {
|
|
724
|
+
throw new OAuthSourceError("preview_invalid", "oauth import: preview is not valid");
|
|
725
|
+
}
|
|
726
|
+
return { kind: ticket.kind, ticketExpiresAt: ticket.ticketExpiresAt };
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
#takeTicket(previewId: string, now: number): Ticket {
|
|
730
|
+
const ticket = this.#tickets.get(previewId);
|
|
731
|
+
if (ticket !== undefined) this.#tickets.delete(previewId);
|
|
732
|
+
this.#purge(now);
|
|
733
|
+
if (ticket === undefined) {
|
|
734
|
+
throw new OAuthSourceError("preview_invalid", "oauth import: preview is not valid");
|
|
735
|
+
}
|
|
736
|
+
if (ticket.ticketExpiresAt <= now) {
|
|
737
|
+
throw new OAuthSourceError("preview_expired", "oauth import: preview has expired");
|
|
738
|
+
}
|
|
739
|
+
return ticket;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
#newPreviewId(): string {
|
|
743
|
+
for (;;) {
|
|
744
|
+
const previewId = randomBytes(PREVIEW_ID_BYTES).toString("base64url");
|
|
745
|
+
if (!this.#tickets.has(previewId)) return previewId;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
#purge(now: number): void {
|
|
750
|
+
for (const [previewId, ticket] of this.#tickets) {
|
|
751
|
+
if (ticket.ticketExpiresAt <= now) this.#tickets.delete(previewId);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** Public commit result plus hidden persist material. */
|
|
757
|
+
export class OAuthImportCommitOutcome {
|
|
758
|
+
readonly result: OAuthImportCommitResult;
|
|
759
|
+
readonly #persist: OAuthSourceCredential | undefined;
|
|
760
|
+
|
|
761
|
+
constructor(result: OAuthImportCommitResult, persist: OAuthSourceCredential | undefined) {
|
|
762
|
+
this.result = result;
|
|
763
|
+
this.#persist = persist;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** Internal. Write this through the destination store; omit to keep dest canonical. */
|
|
767
|
+
takePersist(): OAuthSourceCredential | undefined {
|
|
768
|
+
return this.#persist === undefined ? undefined : cloneCredential(this.#persist);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
toJSON(): OAuthImportCommitResult {
|
|
772
|
+
return this.result;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
export function createOAuthImportSession(options: OAuthImportSessionOptions = {}): OAuthImportSession {
|
|
777
|
+
return new OAuthImportSession(options);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export function classifyOAuthImportConflict(
|
|
781
|
+
incoming: OAuthSourceCredential,
|
|
782
|
+
destination: { status?: OAuthDestinationStatus; credential?: OAuthSourceCredential },
|
|
783
|
+
): OAuthImportConflict {
|
|
784
|
+
const status = destination.status ?? (destination.credential === undefined ? "missing" : "readable");
|
|
785
|
+
if (status === "unsafe") return "unsafe_destination";
|
|
786
|
+
if (status === "unreadable") return "unreadable_destination";
|
|
787
|
+
const current = destination.credential;
|
|
788
|
+
if (status === "missing" || current === undefined) return "none";
|
|
789
|
+
if (current.access === incoming.access && current.refresh === incoming.refresh) return "same_credential";
|
|
790
|
+
const currentAccount = current.accountId;
|
|
791
|
+
const incomingAccount = incoming.accountId;
|
|
792
|
+
if (currentAccount !== undefined && incomingAccount !== undefined) {
|
|
793
|
+
return currentAccount === incomingAccount ? "same_account" : "different_account";
|
|
794
|
+
}
|
|
795
|
+
return "unknown_account";
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
interface ResolvedDestination {
|
|
799
|
+
status: OAuthDestinationStatus;
|
|
800
|
+
credential?: OAuthSourceCredential;
|
|
801
|
+
identity?: OAuthSourceFileIdentity;
|
|
802
|
+
revision?: string;
|
|
803
|
+
/** Internal digest of unreadable dest bytes. Never sent to a client. */
|
|
804
|
+
payloadMac?: string;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function publicPreview(ticket: Ticket, now: number): OAuthImportPreview {
|
|
808
|
+
return {
|
|
809
|
+
previewId: ticket.previewId,
|
|
810
|
+
kind: ticket.kind,
|
|
811
|
+
displayPath: ticket.displayPath,
|
|
812
|
+
expiresAt: ticket.credential.expires,
|
|
813
|
+
ticketExpiresAt: ticket.ticketExpiresAt,
|
|
814
|
+
conflict: ticket.conflict,
|
|
815
|
+
action: oauthImportPreviewAction(ticket.conflict),
|
|
816
|
+
warnings: credentialWarnings(ticket.credential, ticket.conflict, now),
|
|
817
|
+
confirmOverwriteRequired: oauthImportRequiresConfirm(ticket.conflict),
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function credentialWarnings(credential: OAuthSourceCredential, conflict: OAuthImportConflict, now: number): string[] {
|
|
822
|
+
const warnings: string[] = [];
|
|
823
|
+
if (credential.expires <= now) warnings.push("Imported credential is already expired");
|
|
824
|
+
else if (credential.expires - now <= EXPIRING_SOON_MS) warnings.push("Imported credential expires soon");
|
|
825
|
+
if (conflict === "different_account") warnings.push("Stored credential belongs to a different account");
|
|
826
|
+
if (conflict === "same_account") warnings.push("Stored credential for this account will be replaced");
|
|
827
|
+
if (conflict === "unknown_account") warnings.push("Cannot confirm the stored credential belongs to the same account");
|
|
828
|
+
if (conflict === "unreadable_destination") warnings.push("Existing destination credential could not be read");
|
|
829
|
+
return warnings;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function grokOidcEntry(record: Record<string, unknown>, mapKey?: string): OAuthSourceCredential | undefined {
|
|
833
|
+
const access = nonEmptyString(record["key"]);
|
|
834
|
+
const refresh = nonEmptyString(record["refresh_token"]);
|
|
835
|
+
if (access === undefined || refresh === undefined) return undefined;
|
|
836
|
+
const issuer = nonEmptyString(record["oidc_issuer"]) ?? "";
|
|
837
|
+
// Exact normalized origin/host match. Substring checks accept
|
|
838
|
+
// `auth.x.ai.evil.example` and similar spoofs and were replaced.
|
|
839
|
+
const matchesIssuer = issuer !== "" && isApprovedGrokIssuerValue(issuer);
|
|
840
|
+
const matchesKey = mapKey !== undefined && isApprovedGrokIssuerValue(mapKey);
|
|
841
|
+
if (!matchesIssuer && !matchesKey) return undefined;
|
|
842
|
+
const expiresAt = nonEmptyString(record["expires_at"]);
|
|
843
|
+
if (expiresAt === undefined) return undefined;
|
|
844
|
+
const expires = parseRfc3339(expiresAt);
|
|
845
|
+
if (expires === undefined) return undefined;
|
|
846
|
+
return credentialOf(access, refresh, expires, nonEmptyString(record["user_id"]));
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function latestGrokCredential(mapped: readonly OAuthSourceCredential[]): OAuthSourceCredential | undefined {
|
|
850
|
+
let chosen: OAuthSourceCredential | undefined;
|
|
851
|
+
for (const candidate of mapped) {
|
|
852
|
+
if (chosen === undefined || candidate.expires > chosen.expires) chosen = candidate;
|
|
853
|
+
}
|
|
854
|
+
return chosen;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function parseStoredOAuthCredentialDocument(text: string): OAuthSourceCredential | undefined {
|
|
858
|
+
let value: unknown;
|
|
859
|
+
try {
|
|
860
|
+
value = JSON.parse(text);
|
|
861
|
+
} catch {
|
|
862
|
+
return undefined;
|
|
863
|
+
}
|
|
864
|
+
if (!isRecord(value) || value["version"] !== 1) return undefined;
|
|
865
|
+
if (Object.keys(value).some((key) => key !== "version" && key !== "credential")) return undefined;
|
|
866
|
+
const raw = value["credential"];
|
|
867
|
+
if (!isRecord(raw)) return undefined;
|
|
868
|
+
const allowed = new Set(["type", "access", "refresh", "expires", "accountId"]);
|
|
869
|
+
if (Object.keys(raw).some((key) => !allowed.has(key))) return undefined;
|
|
870
|
+
if (raw["type"] !== "oauth") return undefined;
|
|
871
|
+
const access = nonEmptyString(raw["access"]);
|
|
872
|
+
const refresh = nonEmptyString(raw["refresh"]);
|
|
873
|
+
const expires = raw["expires"];
|
|
874
|
+
if (access === undefined || refresh === undefined) return undefined;
|
|
875
|
+
if (typeof expires !== "number" || !Number.isFinite(expires) || expires <= 0) return undefined;
|
|
876
|
+
const accountId = raw["accountId"];
|
|
877
|
+
if (accountId !== undefined && (typeof accountId !== "string" || accountId.length === 0)) return undefined;
|
|
878
|
+
return credentialOf(access, refresh, expires, typeof accountId === "string" ? accountId : undefined);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function credentialOf(
|
|
882
|
+
access: string,
|
|
883
|
+
refresh: string,
|
|
884
|
+
expires: number,
|
|
885
|
+
accountId: string | undefined,
|
|
886
|
+
): OAuthSourceCredential {
|
|
887
|
+
return {
|
|
888
|
+
type: "oauth",
|
|
889
|
+
access,
|
|
890
|
+
refresh,
|
|
891
|
+
expires,
|
|
892
|
+
...(accountId === undefined ? {} : { accountId }),
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function cloneCredential(credential: OAuthSourceCredential): OAuthSourceCredential {
|
|
897
|
+
return credentialOf(credential.access, credential.refresh, credential.expires, credential.accountId);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function parseJsonObject(text: string, label: string): Record<string, unknown> {
|
|
901
|
+
let value: unknown;
|
|
902
|
+
try {
|
|
903
|
+
value = JSON.parse(text);
|
|
904
|
+
} catch {
|
|
905
|
+
throw new OAuthSourceError("invalid_document", `${label} is not valid JSON`);
|
|
906
|
+
}
|
|
907
|
+
if (!isRecord(value)) {
|
|
908
|
+
throw new OAuthSourceError("invalid_document", `${label} must be a JSON object`);
|
|
909
|
+
}
|
|
910
|
+
return value;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function parseRfc3339(value: string): number | undefined {
|
|
914
|
+
const parsed = Date.parse(value);
|
|
915
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
916
|
+
const trimmed = value.replace(/(\.\d{3})\d+/u, "$1");
|
|
917
|
+
const again = Date.parse(trimmed);
|
|
918
|
+
return Number.isFinite(again) && again > 0 ? again : undefined;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** Decode a compact JWT payload. Never logs token material. */
|
|
922
|
+
function jwtPayload(token: string): Record<string, unknown> | undefined {
|
|
923
|
+
const parts = token.split(".");
|
|
924
|
+
if (parts.length !== 3) return undefined;
|
|
925
|
+
const payload = parts[1];
|
|
926
|
+
if (payload === undefined || payload.length === 0) return undefined;
|
|
927
|
+
let json: string;
|
|
928
|
+
try {
|
|
929
|
+
json = Buffer.from(payload, "base64url").toString("utf8");
|
|
930
|
+
} catch {
|
|
931
|
+
return undefined;
|
|
932
|
+
}
|
|
933
|
+
let value: unknown;
|
|
934
|
+
try {
|
|
935
|
+
value = JSON.parse(json);
|
|
936
|
+
} catch {
|
|
937
|
+
return undefined;
|
|
938
|
+
}
|
|
939
|
+
return isRecord(value) ? value : undefined;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function jwtExpMs(token: string): number | undefined {
|
|
943
|
+
const payload = jwtPayload(token);
|
|
944
|
+
if (payload === undefined) return undefined;
|
|
945
|
+
const exp = payload["exp"];
|
|
946
|
+
if (typeof exp !== "number" || !Number.isFinite(exp) || exp <= 0) return undefined;
|
|
947
|
+
return exp * 1000;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function chatgptAccountIdFromAccessJwt(access: string): string | undefined {
|
|
951
|
+
const payload = jwtPayload(access);
|
|
952
|
+
if (payload === undefined) return undefined;
|
|
953
|
+
const auth = payload[OPENAI_AUTH_CLAIM];
|
|
954
|
+
if (!isRecord(auth)) return undefined;
|
|
955
|
+
return nonEmptyString(auth["chatgpt_account_id"]);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function assertSafeStats(stats: Stats, role: "source" | "destination", fromLstat: boolean): void {
|
|
959
|
+
if (fromLstat && stats.isSymbolicLink()) {
|
|
960
|
+
throw new OAuthSourceError("unsafe_source", `oauth ${role}: refusing to follow a symlink`);
|
|
961
|
+
}
|
|
962
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
963
|
+
throw new OAuthSourceError("unsafe_source", `oauth ${role}: path is not a regular file`);
|
|
964
|
+
}
|
|
965
|
+
if (stats.size > OAUTH_SOURCE_MAX_BYTES) {
|
|
966
|
+
throw new OAuthSourceError("too_large", `oauth ${role}: file exceeds the 64KiB limit`);
|
|
967
|
+
}
|
|
968
|
+
if (process.platform === "win32") {
|
|
969
|
+
// Windows has no POSIX owner/mode model. Symlink and identity checks still apply.
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
const uid = currentUid();
|
|
973
|
+
if (uid === undefined) {
|
|
974
|
+
throw new OAuthSourceError("unsafe_source", `oauth ${role}: process uid is required to verify file ownership`);
|
|
975
|
+
}
|
|
976
|
+
if (stats.uid !== uid) {
|
|
977
|
+
throw new OAuthSourceError("unsafe_source", `oauth ${role}: file is not owned by the current user`);
|
|
978
|
+
}
|
|
979
|
+
if ((stats.mode & 0o077) !== 0) {
|
|
980
|
+
throw new OAuthSourceError("unsafe_source", `oauth ${role}: file is readable beyond its owner`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function openFlags(): number {
|
|
985
|
+
if (process.platform === "win32") {
|
|
986
|
+
// Windows has no POSIX O_NOFOLLOW. lstat plus identity checks still refuse symlinks.
|
|
987
|
+
return constants.O_RDONLY;
|
|
988
|
+
}
|
|
989
|
+
if (typeof constants.O_NOFOLLOW !== "number") {
|
|
990
|
+
throw new OAuthSourceError("unsafe_source", "oauth source: O_NOFOLLOW is required to open the file safely");
|
|
991
|
+
}
|
|
992
|
+
return constants.O_RDONLY | constants.O_NOFOLLOW;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function assertSameIdentity(left: Stats, right: Stats): void {
|
|
996
|
+
if (
|
|
997
|
+
left.dev !== right.dev ||
|
|
998
|
+
left.ino !== right.ino ||
|
|
999
|
+
left.uid !== right.uid ||
|
|
1000
|
+
(left.mode & 0o777) !== (right.mode & 0o777) ||
|
|
1001
|
+
left.size !== right.size
|
|
1002
|
+
) {
|
|
1003
|
+
throw new OAuthSourceError("unsafe_source", "oauth source: file identity changed during open");
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function identityOf(stats: Stats): OAuthSourceFileIdentity {
|
|
1008
|
+
return {
|
|
1009
|
+
dev: stats.dev,
|
|
1010
|
+
ino: stats.ino,
|
|
1011
|
+
size: stats.size,
|
|
1012
|
+
uid: stats.uid,
|
|
1013
|
+
mode: stats.mode & 0o777,
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function sameIdentity(left: OAuthSourceFileIdentity, right: OAuthSourceFileIdentity): boolean {
|
|
1018
|
+
return (
|
|
1019
|
+
left.dev === right.dev &&
|
|
1020
|
+
left.ino === right.ino &&
|
|
1021
|
+
left.uid === right.uid &&
|
|
1022
|
+
left.mode === right.mode &&
|
|
1023
|
+
left.size === right.size
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function serializeDestination(destination: ResolvedDestination): string {
|
|
1028
|
+
const credential = destination.credential;
|
|
1029
|
+
const identity = destination.identity;
|
|
1030
|
+
const credentialPart =
|
|
1031
|
+
credential === undefined
|
|
1032
|
+
? ""
|
|
1033
|
+
: ["c", credential.access, credential.refresh, String(credential.expires), credential.accountId ?? ""].join("\0");
|
|
1034
|
+
const identityPart =
|
|
1035
|
+
identity === undefined
|
|
1036
|
+
? ""
|
|
1037
|
+
: [
|
|
1038
|
+
"f",
|
|
1039
|
+
String(identity.dev),
|
|
1040
|
+
String(identity.ino),
|
|
1041
|
+
String(identity.size),
|
|
1042
|
+
String(identity.uid),
|
|
1043
|
+
String(identity.mode),
|
|
1044
|
+
].join("\0");
|
|
1045
|
+
const digestPart =
|
|
1046
|
+
destination.payloadMac === undefined || destination.payloadMac.length === 0
|
|
1047
|
+
? ""
|
|
1048
|
+
: ["h", destination.payloadMac].join("\0");
|
|
1049
|
+
return [destination.status, credentialPart, identityPart, digestPart].join("\n");
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function payloadMacOf(text: string): string {
|
|
1053
|
+
return createHash("sha256").update(text).digest("hex");
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function hmacHex(key: Buffer, data: string): string {
|
|
1057
|
+
return createHmac("sha256", key).update(data).digest("hex");
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
function sameRevision(left: string, right: string): boolean {
|
|
1061
|
+
if (left === right) return true;
|
|
1062
|
+
if (!/^[0-9a-f]+$/iu.test(left) || !/^[0-9a-f]+$/iu.test(right)) return false;
|
|
1063
|
+
const expected = Buffer.from(left, "hex");
|
|
1064
|
+
const actual = Buffer.from(right, "hex");
|
|
1065
|
+
return expected.length === actual.length && expected.length > 0 && timingSafeEqual(expected, actual);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function mapReadError(error: unknown, role: "source" | "destination"): OAuthSourceError {
|
|
1069
|
+
const code = (error as NodeJS.ErrnoException | null)?.code;
|
|
1070
|
+
if (code === "ENOENT") {
|
|
1071
|
+
return new OAuthSourceError("not_found", `oauth ${role}: file not found`);
|
|
1072
|
+
}
|
|
1073
|
+
if (code === "ELOOP" || code === "EMLINK") {
|
|
1074
|
+
return new OAuthSourceError("unsafe_source", `oauth ${role}: refusing to follow a symlink`);
|
|
1075
|
+
}
|
|
1076
|
+
if (code === "EISDIR" || code === "ENOTDIR") {
|
|
1077
|
+
return new OAuthSourceError("unsafe_source", `oauth ${role}: path is not a regular file`);
|
|
1078
|
+
}
|
|
1079
|
+
if (code === "EACCES" || code === "EPERM") {
|
|
1080
|
+
return new OAuthSourceError("unsafe_source", `oauth ${role}: file is not readable by the current user`);
|
|
1081
|
+
}
|
|
1082
|
+
return new OAuthSourceError("unsafe_source", `oauth ${role}: file could not be opened safely`);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function unavailableReason(error: unknown): OAuthSourceUnavailableReason {
|
|
1086
|
+
if (error instanceof OAuthSourceError) {
|
|
1087
|
+
if (error.code === "not_found") return "missing";
|
|
1088
|
+
if (error.code === "too_large") return "too_large";
|
|
1089
|
+
if (error.code === "unsafe_source" || error.code === "unsafe_destination") return "unsafe";
|
|
1090
|
+
}
|
|
1091
|
+
return "invalid";
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function currentUid(): number | undefined {
|
|
1095
|
+
return typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1099
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
function nonEmptyString(value: unknown): string | undefined {
|
|
1103
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
1104
|
+
}
|