@cortexkit/anthropic-auth-core 2.2.7 → 2.2.8

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/dist/index.d.ts CHANGED
@@ -62,6 +62,7 @@ export type { NativePiAuthSlot, NativePiAuthSlotErrorCode, NativePiAuthSlotInspe
62
62
  export { disconnectNativeVaultForHost, NativePiAuthSlotError, nativePiAuthSlotPaths, openNativePiAuthSlot, } from './native-vault-connection.ts';
63
63
  export type { NativeVaultQuotaPurpose, NativeVaultRuntime, NativeVaultRuntimeOptions, NativeVaultWatch, NativeVaultWatchOptions, } from './native-vault-runtime.ts';
64
64
  export { acquireNativeVaultRuntime, createNativeVaultRuntime, discoverNativeVaultInventory, publishNativeVaultRosterSeed, publishNativeVaultRuntimeSeed, } from './native-vault-runtime.ts';
65
+ export * from './oauth-origin.ts';
65
66
  export * from './oauth-profile.ts';
66
67
  export { createWindowsOwnerOnly, ensureWindowsOwnerOnlyDirectory, isOwnerOnly, type OwnerOnlyOptions, } from './owner-only.ts';
67
68
  export * from './pkce.ts';
package/dist/index.js CHANGED
@@ -4918,6 +4918,7 @@ async function fetchOAuthQuotaSnapshot(input) {
4918
4918
  const fetchImpl = input.fetchImpl ?? fetch;
4919
4919
  const response = await fetchImpl(QUOTA_URL, {
4920
4920
  method: "GET",
4921
+ redirect: "error",
4921
4922
  headers: {
4922
4923
  Authorization: `Bearer ${input.accessToken}`,
4923
4924
  Accept: "application/json",
@@ -9397,6 +9398,7 @@ async function fetchOAuthAccountProfile(input) {
9397
9398
  assertNotCustodyTombstone(input.accessToken, "anthropic");
9398
9399
  const response = await (input.fetchImpl ?? fetch)(PROFILE_URL, {
9399
9400
  method: "GET",
9401
+ redirect: "error",
9400
9402
  signal: input.signal,
9401
9403
  headers: {
9402
9404
  Authorization: `Bearer ${input.accessToken}`,
@@ -25827,8 +25829,10 @@ async function sendViaRelay(options) {
25827
25829
  dumpTag,
25828
25830
  onDumpCreated,
25829
25831
  setTimeoutImpl = globalThis.setTimeout,
25830
- clearTimeoutImpl = globalThis.clearTimeout
25832
+ clearTimeoutImpl = globalThis.clearTimeout,
25833
+ requireUpstreamUrl
25831
25834
  } = options;
25835
+ requireUpstreamUrl?.(input instanceof Request ? input.url : input.toString());
25832
25836
  if (!config || !isRelayableAnthropicRequest(input, body))
25833
25837
  return fallback();
25834
25838
  const affinity = explicitAffinity || headers.get("x-session-affinity") || headers.get("x-opencode-session");
@@ -31363,6 +31367,57 @@ async function disconnectNativeVaultForHost(options) {
31363
31367
  closeNativeVaultAuthority(paths.tokenPath);
31364
31368
  return result;
31365
31369
  }
31370
+ // src/oauth-origin.ts
31371
+ var ANTHROPIC_OAUTH_ORIGIN = "https://api.anthropic.com";
31372
+ var ANTHROPIC_OAUTH_ORIGIN_REFUSAL = "Claude OAuth requests can only go to https://api.anthropic.com. A provider baseURL that points elsewhere is not used for Claude sign-in accounts.";
31373
+
31374
+ class AnthropicOAuthOriginError extends Error {
31375
+ check = "oauth_destination";
31376
+ constructor() {
31377
+ super(ANTHROPIC_OAUTH_ORIGIN_REFUSAL);
31378
+ this.name = "AnthropicOAuthOriginError";
31379
+ }
31380
+ }
31381
+ function sameOrigin(url, origin) {
31382
+ let allowed;
31383
+ try {
31384
+ allowed = new URL(origin);
31385
+ } catch {
31386
+ return false;
31387
+ }
31388
+ return url.protocol === allowed.protocol && url.origin === allowed.origin;
31389
+ }
31390
+ function requireAnthropicOAuthOrigin(value, options = {}) {
31391
+ const refuse5 = options.refusal ?? (() => new AnthropicOAuthOriginError);
31392
+ let url;
31393
+ try {
31394
+ url = new URL(value);
31395
+ } catch {
31396
+ throw refuse5("unparseable");
31397
+ }
31398
+ if (url.username || url.password)
31399
+ throw refuse5("destination");
31400
+ if (url.protocol === "https:" && url.origin === ANTHROPIC_OAUTH_ORIGIN)
31401
+ return url;
31402
+ if (options.extraOrigin && sameOrigin(url, options.extraOrigin))
31403
+ return url;
31404
+ throw refuse5("destination");
31405
+ }
31406
+ function fetchInputUrl(input) {
31407
+ return input instanceof Request ? input.url : String(input);
31408
+ }
31409
+ function createAnthropicOAuthFetch(options = {}) {
31410
+ const current = () => typeof options === "function" ? options() : options;
31411
+ return Object.assign(async (input, init) => {
31412
+ requireAnthropicOAuthOrigin(fetchInputUrl(input), current());
31413
+ return fetch(input, { ...init, redirect: "error" });
31414
+ }, {
31415
+ preconnect: (...args) => {
31416
+ requireAnthropicOAuthOrigin(String(args[0]), current());
31417
+ return fetch.preconnect(...args);
31418
+ }
31419
+ });
31420
+ }
31366
31421
  // src/prime.ts
31367
31422
  import { createHash as createHash21 } from "node:crypto";
31368
31423
  import { realpathSync as realpathSync2 } from "node:fs";
@@ -33093,6 +33148,7 @@ export {
33093
33148
  resetCache1hState,
33094
33149
  resetBillingHeaderCCH,
33095
33150
  requireNoSupervisedAuthContentSnapshot,
33151
+ requireAnthropicOAuthOrigin,
33096
33152
  requestGzipPadding,
33097
33153
  remapRequestBodyModel,
33098
33154
  remapModelId,
@@ -33328,6 +33384,7 @@ export {
33328
33384
  createNativeCustody,
33329
33385
  createNativeAccountRuntime,
33330
33386
  createEmptyStorage,
33387
+ createAnthropicOAuthFetch,
33331
33388
  countUserPrompts,
33332
33389
  connectClaustrumEnrollmentClient2 as connectClaustrumEnrollmentClient,
33333
33390
  configuredAnthropicOAuthAccountCount,
@@ -33524,8 +33581,11 @@ export {
33524
33581
  CACHE_KEEP_EXTENDED_TTL_BETA,
33525
33582
  CACHE_1H_MODES,
33526
33583
  CACHE_1H_COMMAND_NAME,
33584
+ AnthropicOAuthOriginError,
33527
33585
  AXIOS_USER_AGENT,
33528
33586
  AUTHORIZE_URLS,
33587
+ ANTHROPIC_OAUTH_ORIGIN_REFUSAL,
33588
+ ANTHROPIC_OAUTH_ORIGIN,
33529
33589
  ANTHROPIC_CUSTOM_HEADERS_ENV,
33530
33590
  ACCOUNT_STATE_FILE_NAME,
33531
33591
  ACCOUNT_FILE_NAME
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Where a Claude OAuth access token may be sent.
3
+ *
4
+ * A Claude sign-in (OAuth) access token is sent only to Anthropic's own HTTPS
5
+ * API server, unless a caller explicitly allows one test-only loopback origin
6
+ * (see `extraOrigin`). Hosts take the request URL from provider configuration,
7
+ * and that configuration can come from a project file in a repository the
8
+ * user opened, so the URL alone is not trusted: every OAuth send checks its
9
+ * final URL here before the token leaves the process. API-key routes are not
10
+ * checked here; they keep whatever base URL the user configured.
11
+ */
12
+ export declare const ANTHROPIC_OAUTH_ORIGIN = "https://api.anthropic.com";
13
+ /** Fixed refusal text. It never includes the refused URL or any credential. */
14
+ export declare const ANTHROPIC_OAUTH_ORIGIN_REFUSAL = "Claude OAuth requests can only go to https://api.anthropic.com. A provider baseURL that points elsewhere is not used for Claude sign-in accounts.";
15
+ /**
16
+ * A Claude OAuth request was refused locally because its destination is not
17
+ * Anthropic's API server. Nothing was sent, and another account cannot help,
18
+ * so callers treat it as a terminal request error, not an account failure.
19
+ */
20
+ export declare class AnthropicOAuthOriginError extends Error {
21
+ readonly check = "oauth_destination";
22
+ constructor();
23
+ }
24
+ export interface AnthropicOAuthOriginOptions {
25
+ /**
26
+ * One more exact origin (scheme, host and port) the caller accepts besides
27
+ * Anthropic's. Hosts use it only for a test-only loopback server; it is
28
+ * never derived from provider configuration.
29
+ */
30
+ readonly extraOrigin?: string;
31
+ /**
32
+ * Builds the error thrown for a refused URL. `unparseable` means the value
33
+ * is not a URL at all. Defaults to AnthropicOAuthOriginError for both.
34
+ */
35
+ readonly refusal?: (reason: 'unparseable' | 'destination') => Error;
36
+ }
37
+ /**
38
+ * Returns the parsed URL when an OAuth token may be sent to it: HTTPS on
39
+ * api.anthropic.com's default port (or the caller's extra origin), with no
40
+ * user name or password in the URL. Throws otherwise.
41
+ */
42
+ export declare function requireAnthropicOAuthOrigin(value: string, options?: AnthropicOAuthOriginOptions): URL;
43
+ /**
44
+ * A fetch for OAuth sends. It checks each URL with
45
+ * requireAnthropicOAuthOrigin before dispatch and always sets
46
+ * `redirect: 'error'`, so an approved destination cannot forward the token
47
+ * elsewhere with a redirect. It calls the global fetch at call time, so test
48
+ * stubs installed later still apply. Options are read on every call when
49
+ * given as a function.
50
+ */
51
+ export declare function createAnthropicOAuthFetch(options?: AnthropicOAuthOriginOptions | (() => AnthropicOAuthOriginOptions)): typeof fetch;
package/dist/relay.d.ts CHANGED
@@ -76,5 +76,11 @@ export declare function sendViaRelay(options: {
76
76
  onDumpCreated?: (handle: import('./dump.ts').DumpHandle) => void;
77
77
  setTimeoutImpl?: typeof globalThis.setTimeout;
78
78
  clearTimeoutImpl?: typeof globalThis.clearTimeout;
79
+ /**
80
+ * Checks the URL the relay will forward to (the envelope's upstream.url)
81
+ * and throws to refuse it. Runs before anything is sent, relay or direct;
82
+ * its error reaches the caller unchanged and never triggers a fallback.
83
+ */
84
+ requireUpstreamUrl?: (url: string) => unknown;
79
85
  }): Promise<Response>;
80
86
  export declare const WORKER_SCRIPT = "\nasync function hashBody(body) {\n const bytes = new TextEncoder().encode(body)\n const digest = await crypto.subtle.digest('SHA-256', bytes)\n const hex = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')\n return 'sha256:' + hex\n}\n\nfunction applyPatch(base, patch) {\n return base.slice(0, patch.start) + patch.insert + base.slice(patch.start + patch.deleteCount)\n}\n\nfunction applyPatchSet(base, patch) {\n if (!Array.isArray(patch)) return applyPatch(base, patch)\n if (patch.length === 0) return base\n\n let result = ''\n let cursor = 0\n for (const item of patch) {\n if (item.start < cursor) return { error: 'overlapping patch', status: 400 }\n result += base.slice(cursor, item.start)\n result += item.insert\n cursor = item.start + item.deleteCount\n }\n return result + base.slice(cursor)\n}\n\nasync function readState(env, affinity) {\n const raw = await env.RELAY_STATE.get('session:' + affinity)\n return raw ? JSON.parse(raw) : null\n}\n\nasync function writeState(env, affinity, state) {\n await env.RELAY_STATE.put('session:' + affinity, JSON.stringify(state), { expirationTtl: 86400 })\n}\n\nasync function resolveBody(env, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n const state = await readState(env, payload.affinity)\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nasync function prepareUpstream(env, payload) {\n if ((payload.protocol !== 1 && payload.protocol !== 2) || payload.type !== 'request' || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = await resolveBody(env, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n\n if (typeof body !== 'string' || (await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n const stateWrite = writeState(env, payload.affinity, { body, hash: payload.next_hash, revision: payload.revision }).catch(() => {})\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'relay',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body, stateWrite }\n}\n\nfunction resolveBodyFromState(state, payload) {\n if (payload.mode === 'full_sync') return payload.body\n if (payload.mode === 'patch') {\n if (!state || state.hash !== payload.base_hash) {\n return { error: 'state mismatch', status: 409 }\n }\n return applyPatchSet(state.body, payload.patch)\n }\n return { error: 'unknown mode', status: 400 }\n}\n\nfunction deferWorkerTask(task) {\n return new Promise((resolve) => setTimeout(resolve, 0)).then(task)\n}\n\nconst WEBSOCKET_STREAM_FLUSH_BYTES = 1024\nconst WEBSOCKET_STREAM_FLUSH_MS = 20\n\nfunction createWebSocketStreamCoalescer(socket, options = {}) {\n const flushBytes = options.flushBytes || WEBSOCKET_STREAM_FLUSH_BYTES\n const flushMs = options.flushMs ?? WEBSOCKET_STREAM_FLUSH_MS\n let chunks = []\n let byteLength = 0\n let flushTimer = null\n const stats = {\n upstreamChunks: 0,\n upstreamBytes: 0,\n frames: 0,\n frameBytes: 0,\n }\n\n const clearFlushTimer = () => {\n if (flushTimer === null) return\n clearTimeout(flushTimer)\n flushTimer = null\n }\n\n const mergeChunks = () => {\n if (chunks.length === 1) return chunks[0]\n const frame = new Uint8Array(byteLength)\n let offset = 0\n for (const chunk of chunks) {\n frame.set(chunk, offset)\n offset += chunk.byteLength\n }\n return frame\n }\n\n const flush = () => {\n clearFlushTimer()\n if (byteLength === 0) return\n const frame = mergeChunks()\n chunks = []\n byteLength = 0\n socket.send(frame)\n stats.frames += 1\n stats.frameBytes += frame.byteLength\n }\n\n const scheduleFlush = () => {\n if (flushTimer !== null) return\n flushTimer = setTimeout(flush, flushMs)\n }\n\n return {\n push(value) {\n const chunk = value instanceof Uint8Array ? value : new Uint8Array(value)\n if (chunk.byteLength === 0) return\n stats.upstreamChunks += 1\n stats.upstreamBytes += chunk.byteLength\n chunks.push(chunk)\n byteLength += chunk.byteLength\n if (byteLength >= flushBytes) flush()\n else scheduleFlush()\n },\n flush,\n stats() {\n return { ...stats, pendingBytes: byteLength }\n },\n }\n}\n\nfunction checkpointWebSocketState(env, payload, body, nextState) {\n return deferWorkerTask(async () => {\n try {\n if ((await hashBody(body)) !== payload.next_hash) {\n throw new Error('hash mismatch')\n }\n await writeState(env, payload.affinity, nextState)\n } catch {\n console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'websocket',\n event: 'checkpoint_write_failed',\n affinity: String(payload.affinity).slice(0, 12),\n code: 'checkpoint_write_error',\n }))\n }\n })\n}\n\nasync function prepareWebSocketUpstream(env, state, payload) {\n if (payload.protocol !== 2 || payload.type !== 'request' || !payload.id || !payload.affinity || !payload.upstream?.url || !payload.next_hash) {\n return { error: 'invalid payload', status: 400 }\n }\n\n const body = resolveBodyFromState(state, payload)\n if (body && typeof body === 'object' && 'error' in body) return body\n if (typeof body !== 'string') return { error: 'invalid body', status: 400 }\n if ((await hashBody(body)) !== payload.next_hash) {\n return { error: 'hash mismatch', status: 409 }\n }\n\n const nextState = { body, hash: payload.next_hash, revision: payload.revision }\n const checkpoint = checkpointWebSocketState(env, payload, body, nextState)\n const logAccepted = () => console.log(JSON.stringify({\n relay: 'opencode-anthropic-auth',\n transport: 'websocket',\n mode: payload.mode,\n revision: payload.revision,\n affinity: String(payload.affinity).slice(0, 12),\n bodyBytes: body.length,\n }))\n\n return { body, state: nextState, checkpoint, logAccepted }\n}\n\nfunction headersToObject(headers) {\n const result = {}\n for (const [key, value] of headers.entries()) result[key] = value\n return result\n}\n\nasync function handleRelayPayload(env, payload) {\n const prepared = await prepareUpstream(env, payload)\n if (prepared.error) return prepared\n const upstream = await fetch(payload.upstream.url, {\n method: payload.upstream.method || 'POST',\n headers: payload.upstream.headers,\n body: prepared.body,\n })\n return { upstream, stateWrite: prepared.stateWrite }\n}\n\nasync function handleWebSocket(socket, env, ctx, payload, getState, setState) {\n const heartbeat = setInterval(() => {\n try {\n socket.send(JSON.stringify({ protocol: 2, type: 'keepalive' }))\n } catch {\n clearInterval(heartbeat)\n }\n }, 15000)\n let streamCoalescer = null\n try {\n const result = await prepareWebSocketUpstream(env, getState(), payload)\n if (result.error) {\n socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: result.status, message: result.error }))\n return\n }\n\n setState(result.state)\n socket.send(JSON.stringify({ protocol: 2, type: 'accepted', id: payload.id, hash: result.state.hash, revision: result.state.revision }))\n ctx?.waitUntil?.(deferWorkerTask(result.logAccepted))\n\n const upstreamPromise = fetch(payload.upstream.url, {\n method: payload.upstream.method || 'POST',\n headers: payload.upstream.headers,\n body: result.body,\n })\n ctx?.waitUntil?.(result.checkpoint)\n const upstream = await upstreamPromise\n socket.send(JSON.stringify({\n protocol: 2,\n type: 'response_start',\n id: payload.id,\n status: upstream.status,\n statusText: upstream.statusText,\n headers: headersToObject(upstream.headers),\n }))\n\n const reader = upstream.body?.getReader()\n if (reader) {\n streamCoalescer = createWebSocketStreamCoalescer(socket)\n while (true) {\n const { done, value } = await reader.read()\n if (done) break\n streamCoalescer.push(value)\n }\n streamCoalescer.flush()\n }\n const stats = streamCoalescer?.stats?.() || {}\n socket.send(JSON.stringify({ protocol: 2, type: 'done', id: payload.id, ...stats }))\n } catch (error) {\n try {\n streamCoalescer?.flush?.()\n } catch {}\n socket.send(JSON.stringify({ protocol: 2, type: 'error', id: payload.id, status: 500, message: error instanceof Error ? error.message : String(error) }))\n } finally {\n clearInterval(heartbeat)\n }\n}\n\nexport default {\n async fetch(request, env, ctx) {\n if (request.headers.get('Upgrade') === 'websocket') {\n const url = new URL(request.url)\n const token = url.searchParams.get('token')\n const affinity = url.searchParams.get('affinity')\n if (token !== env.RELAY_TOKEN) return new Response('unauthorized', { status: 401 })\n if (!affinity) return new Response('missing affinity', { status: 400 })\n const pair = new WebSocketPair()\n const client = pair[0]\n const server = pair[1]\n server.binaryType = 'arraybuffer'\n let state = null\n let ready = false\n server.accept()\n const loadState = readState(env, affinity).then((loadedState) => {\n state = loadedState\n ready = true\n server.send(JSON.stringify({\n protocol: 2,\n type: 'ready',\n state: state ? { hash: state.hash, revision: state.revision } : null,\n }))\n }).catch((error) => {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 500, message: error instanceof Error ? error.message : String(error) }))\n server.close(1011, 'state load failed')\n })\n ctx?.waitUntil?.(loadState)\n if (!ctx?.waitUntil) void loadState\n let busy = false\n server.addEventListener('message', (event) => {\n if (!ready) {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 425, message: 'relay state is not ready' }))\n return\n }\n if (busy) {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 429, message: 'request already in flight' }))\n return\n }\n let payload\n try {\n payload = JSON.parse(event.data)\n } catch {\n server.send(JSON.stringify({ protocol: 2, type: 'error', status: 400, message: 'invalid JSON payload' }))\n return\n }\n payload.affinity = affinity\n busy = true\n const run = handleWebSocket(server, env, ctx, payload, () => state, (nextState) => { state = nextState }).finally(() => { busy = false })\n ctx?.waitUntil?.(run)\n if (!ctx?.waitUntil) void run\n })\n return new Response(null, { status: 101, webSocket: client })\n }\n\n if (request.method === 'GET') {\n return Response.json({ status: 'ok', transports: ['http', 'websocket'] })\n }\n if (request.method !== 'POST') return new Response('method not allowed', { status: 405 })\n if (request.headers.get('x-relay-token') !== env.RELAY_TOKEN) {\n return new Response('unauthorized', { status: 401 })\n }\n\n try {\n const payload = await request.json()\n const result = await handleRelayPayload(env, payload)\n if (result.error) return Response.json({ error: result.error }, { status: result.status })\n\n if (result.stateWrite) ctx.waitUntil(result.stateWrite)\n\n const upstream = result.upstream\n return new Response(upstream.body, {\n status: upstream.status,\n statusText: upstream.statusText,\n headers: upstream.headers,\n })\n } catch (error) {\n return Response.json(\n { error: error instanceof Error ? error.message : 'internal relay error' },\n { status: 502 },\n )\n }\n },\n}\n";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cortexkit/anthropic-auth-core",
3
- "version": "2.2.7",
3
+ "version": "2.2.8",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",