@app-vitals/agent-admin 0.0.0-stage → 0.1.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/Dockerfile +43 -0
- package/README.md +72 -2
- package/e2e/agents-page.e2e.ts +338 -0
- package/e2e/chat-degraded.e2e.ts +153 -0
- package/e2e/chat-page.e2e.ts +169 -0
- package/e2e/chat-progress.e2e.ts +206 -0
- package/e2e/chat-thread-page.e2e.ts +444 -0
- package/e2e/login-page.e2e.ts +148 -0
- package/e2e/test-server.ts +427 -0
- package/openapi.json +6098 -0
- package/openapi.platform.json +5553 -0
- package/package.json +64 -4
- package/playwright.config.ts +25 -0
- package/prisma/client/browser.ts +134 -0
- package/prisma/client/client.ts +158 -0
- package/prisma/client/commonInputTypes.ts +578 -0
- package/prisma/client/enums.ts +15 -0
- package/prisma/client/internal/class.ts +424 -0
- package/prisma/client/internal/prismaNamespace.ts +2898 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
- package/prisma/client/models/Account.ts +1774 -0
- package/prisma/client/models/AccountInvite.ts +1386 -0
- package/prisma/client/models/AccountMember.ts +1351 -0
- package/prisma/client/models/Agent.ts +4554 -0
- package/prisma/client/models/AgentAsk.ts +1373 -0
- package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
- package/prisma/client/models/AgentCronJob.ts +2416 -0
- package/prisma/client/models/AgentCronRun.ts +2858 -0
- package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
- package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
- package/prisma/client/models/AgentEnv.ts +1421 -0
- package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
- package/prisma/client/models/AgentMember.ts +1316 -0
- package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
- package/prisma/client/models/AgentPlugin.ts +1421 -0
- package/prisma/client/models/AgentToken.ts +1380 -0
- package/prisma/client/models/AgentTool.ts +1351 -0
- package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
- package/prisma/client/models/ChatThreadWatch.ts +1183 -0
- package/prisma/client/models/PushSubscription.ts +1233 -0
- package/prisma/client/models/SessionAlertState.ts +1187 -0
- package/prisma/client/models/SessionFollow.ts +1187 -0
- package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
- package/prisma/client/models.ts +34 -0
- package/prisma/migrations/20260608000000_init/migration.sql +112 -0
- package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
- package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
- package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
- package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
- package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
- package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
- package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
- package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
- package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
- package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
- package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
- package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
- package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
- package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
- package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
- package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
- package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
- package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
- package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
- package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
- package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
- package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
- package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
- package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
- package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
- package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
- package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
- package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
- package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
- package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
- package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
- package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
- package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
- package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
- package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
- package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
- package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
- package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
- package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema/coding.prisma +85 -0
- package/prisma/schema/schema.prisma +506 -0
- package/prisma.config.ts +34 -0
- package/pwa-assets/icons/apple-touch-icon.png +0 -0
- package/pwa-assets/icons/favicon-32.png +0 -0
- package/pwa-assets/icons/icon-192.png +0 -0
- package/pwa-assets/icons/icon-512.png +0 -0
- package/pwa-assets/icons/icon-maskable-192.png +0 -0
- package/pwa-assets/icons/icon-maskable-512.png +0 -0
- package/src/account-agent-create.ts +42 -0
- package/src/account-agent-create.unit.test.ts +28 -0
- package/src/account-created-notifier.ts +71 -0
- package/src/account-created-notifier.unit.test.ts +70 -0
- package/src/account-email.ts +9 -0
- package/src/account-email.unit.test.ts +21 -0
- package/src/account-invites.ts +70 -0
- package/src/account-lifecycle.integration.test.ts +329 -0
- package/src/account-lifecycle.ts +206 -0
- package/src/account-lifecycle.unit.test.ts +108 -0
- package/src/account-members.ts +128 -0
- package/src/account-onboarding.integration.test.ts +101 -0
- package/src/account-onboarding.ts +95 -0
- package/src/account-trial-expiry-sweeper.ts +85 -0
- package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
- package/src/account-trial-warning-sweeper.ts +216 -0
- package/src/accounts-api.integration.test.ts +196 -0
- package/src/accounts-api.smoke.test.ts +292 -0
- package/src/accounts-api.ts +289 -0
- package/src/accounts-api.unit.test.ts +93 -0
- package/src/accounts.integration.test.ts +178 -0
- package/src/accounts.ts +208 -0
- package/src/admin-boot-config.ts +574 -0
- package/src/admin-boot-config.unit.test.ts +28 -0
- package/src/admin-boot.boot-order.unit.test.ts +91 -0
- package/src/admin-boot.ts +690 -0
- package/src/admin-dev-login.smoke.test.ts +317 -0
- package/src/admin-extension.ts +432 -0
- package/src/admin-extension.unit.test.ts +532 -0
- package/src/admin-local-agent.smoke.test.ts +1643 -0
- package/src/admin-spec.smoke.test.ts +96 -0
- package/src/admin-spec.ts +186 -0
- package/src/admin-ui-account-nav.ts +20 -0
- package/src/admin-ui-account-pages.ts +131 -0
- package/src/admin-ui-account-pages.unit.test.ts +112 -0
- package/src/admin-ui-account.smoke.test.ts +286 -0
- package/src/admin-ui-account.ts +185 -0
- package/src/admin-ui-accounts-pages.ts +202 -0
- package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
- package/src/admin-ui-accounts.smoke.test.ts +434 -0
- package/src/admin-ui-accounts.ts +266 -0
- package/src/admin-ui-layout.ts +60 -0
- package/src/admin-ui-layout.unit.test.ts +140 -0
- package/src/admin-ui-pages.ts +3489 -0
- package/src/admin-ui-pages.unit.test.ts +4355 -0
- package/src/admin-ui-push-test.smoke.test.ts +125 -0
- package/src/admin-ui-push-test.ts +102 -0
- package/src/admin-ui-styles.ts +656 -0
- package/src/admin-ui-styles.unit.test.ts +103 -0
- package/src/admin-ui.fixtures.ts +457 -0
- package/src/admin-ui.smoke.test.ts +7227 -0
- package/src/admin-ui.ts +3824 -0
- package/src/agent-account-assignment.ts +49 -0
- package/src/agent-account-assignment.unit.test.ts +99 -0
- package/src/agent-anthropic-identity.integration.test.ts +99 -0
- package/src/agent-anthropic-identity.ts +78 -0
- package/src/agent-anthropic-identity.unit.test.ts +77 -0
- package/src/agent-ask-config.ts +59 -0
- package/src/agent-ask-config.unit.test.ts +70 -0
- package/src/agent-ask.integration.test.ts +175 -0
- package/src/agent-ask.smoke.test.ts +457 -0
- package/src/agent-ask.ts +378 -0
- package/src/agent-chat-tokens.integration.test.ts +420 -0
- package/src/agent-chat-tokens.smoke.test.ts +551 -0
- package/src/agent-chat-tokens.ts +259 -0
- package/src/agent-cron-jobs.integration.test.ts +582 -0
- package/src/agent-cron-jobs.ts +615 -0
- package/src/agent-cron-jobs.unit.test.ts +420 -0
- package/src/agent-cron-run-stats.integration.test.ts +1551 -0
- package/src/agent-cron-run-stats.smoke.test.ts +953 -0
- package/src/agent-cron-run-stats.ts +752 -0
- package/src/agent-cron-runs.integration.test.ts +1491 -0
- package/src/agent-cron-runs.ts +489 -0
- package/src/agent-deletion-checklist.ts +128 -0
- package/src/agent-deletion-checklist.unit.test.ts +201 -0
- package/src/agent-deletion-per-agent.integration.test.ts +323 -0
- package/src/agent-deletion.integration.test.ts +323 -0
- package/src/agent-deletion.ts +329 -0
- package/src/agent-deletion.unit.test.ts +412 -0
- package/src/agent-envs.integration.test.ts +323 -0
- package/src/agent-envs.ts +244 -0
- package/src/agent-github-installations.integration.test.ts +115 -0
- package/src/agent-github-installations.ts +52 -0
- package/src/agent-manifest.ts +685 -0
- package/src/agent-manifest.unit.test.ts +870 -0
- package/src/agent-members.ts +69 -0
- package/src/agent-members.unit.test.ts +250 -0
- package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
- package/src/agent-plugins.integration.test.ts +157 -0
- package/src/agent-plugins.ts +95 -0
- package/src/agent-plugins.unit.test.ts +43 -0
- package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
- package/src/agent-provisioner.integration.test.ts +1245 -0
- package/src/agent-provisioner.ts +1650 -0
- package/src/agent-provisioner.unit.test.ts +1852 -0
- package/src/agent-tokens.integration.test.ts +150 -0
- package/src/agent-tokens.ts +112 -0
- package/src/agent-tools.integration.test.ts +129 -0
- package/src/agent-tools.ts +111 -0
- package/src/agents-api.integration.test.ts +705 -0
- package/src/agents-api.sentry.smoke.test.ts +478 -0
- package/src/agents-api.smoke.test.ts +5986 -0
- package/src/agents-api.ts +2826 -0
- package/src/agents.integration.test.ts +512 -0
- package/src/agents.ts +1097 -0
- package/src/agents.unit.test.ts +1736 -0
- package/src/anthropic-credential-guard.ts +44 -0
- package/src/anthropic-credential-guard.unit.test.ts +88 -0
- package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
- package/src/anthropic-federated-token-provider.ts +249 -0
- package/src/anthropic-identity-client.fixtures.ts +237 -0
- package/src/anthropic-identity-client.integration.test.ts +342 -0
- package/src/anthropic-identity-client.ts +473 -0
- package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
- package/src/anthropic-identity-name.integration.test.ts +83 -0
- package/src/anthropic-identity-name.ts +163 -0
- package/src/anthropic-identity-name.unit.test.ts +345 -0
- package/src/anthropic-openapi-contract.fixtures.ts +192 -0
- package/src/api-auth.ts +383 -0
- package/src/api-auth.unit.test.ts +837 -0
- package/src/api.smoke.test.ts +1225 -0
- package/src/api.ts +376 -0
- package/src/attachment-validation.ts +124 -0
- package/src/attachment-validation.unit.test.ts +218 -0
- package/src/caller-scope.ts +131 -0
- package/src/caller-scope.unit.test.ts +116 -0
- package/src/chat-credential-config.ts +22 -0
- package/src/chat-credentials-logging.unit.test.ts +92 -0
- package/src/chat-markers.ts +114 -0
- package/src/chat-markers.unit.test.ts +116 -0
- package/src/chat-service-provisioning-client.integration.test.ts +412 -0
- package/src/chat-service-provisioning-client.ts +221 -0
- package/src/chat.smoke.test.ts +1875 -0
- package/src/clock.ts +47 -0
- package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
- package/src/coding-task-store-provisioning-client.ts +156 -0
- package/src/crypto.ts +83 -0
- package/src/dev-auth-guard.ts +29 -0
- package/src/dev-auth-guard.unit.test.ts +51 -0
- package/src/errors.ts +77 -0
- package/src/errors.unit.test.ts +52 -0
- package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
- package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
- package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
- package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
- package/src/fixtures/github-app-manifest-cassette.json +44 -0
- package/src/fixtures/google-auth-cassette.json +36 -0
- package/src/fixtures/http-chat-client-cassette.json +226 -0
- package/src/fixtures/k8s-cassette.json +470 -0
- package/src/fixtures/okta-auth-cassette.json +36 -0
- package/src/fixtures/slack-provision-cassette.json +12 -0
- package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
- package/src/fixtures/task-store-provisioning-cassette.json +85 -0
- package/src/github-app-provisioning-client.integration.test.ts +149 -0
- package/src/github-app-provisioning-client.ts +161 -0
- package/src/github-app-provisioning-client.unit.test.ts +114 -0
- package/src/github-provisioning-service.ts +569 -0
- package/src/github-provisioning-service.unit.test.ts +123 -0
- package/src/google-auth-client.integration.test.ts +195 -0
- package/src/google-auth-client.ts +121 -0
- package/src/http-chat-client.integration.test.ts +554 -0
- package/src/http-chat-client.ts +533 -0
- package/src/http-chat-client.unit.test.ts +119 -0
- package/src/import-boundary.unit.test.ts +240 -0
- package/src/kubernetes-client.integration.test.ts +697 -0
- package/src/kubernetes-client.ts +1027 -0
- package/src/kubernetes-client.unit.test.ts +726 -0
- package/src/main.smoke.test.ts +91 -0
- package/src/main.ts +21 -0
- package/src/main.unit.test.ts +801 -0
- package/src/okta-auth-client.integration.test.ts +263 -0
- package/src/okta-auth-client.ts +152 -0
- package/src/openapi-schemas.ts +1310 -0
- package/src/openapi-schemas.unit.test.ts +348 -0
- package/src/per-agent-identity.fixtures.ts +305 -0
- package/src/platform-doc-route.ts +40 -0
- package/src/prisma-client.integration.test.ts +57 -0
- package/src/prisma-client.ts +187 -0
- package/src/prisma-client.unit.test.ts +178 -0
- package/src/prisma-tx.ts +23 -0
- package/src/push-content.ts +177 -0
- package/src/push-content.unit.test.ts +213 -0
- package/src/push-crypto.ts +135 -0
- package/src/push-crypto.unit.test.ts +66 -0
- package/src/push-sender.ts +169 -0
- package/src/push-sender.unit.test.ts +107 -0
- package/src/push-service.ts +332 -0
- package/src/push-service.unit.test.ts +679 -0
- package/src/push-toggle.ts +135 -0
- package/src/push-toggle.unit.test.ts +78 -0
- package/src/pwa-https-gating.smoke.test.ts +229 -0
- package/src/pwa-icons.unit.test.ts +41 -0
- package/src/pwa-routes.smoke.test.ts +276 -0
- package/src/pwa.ts +380 -0
- package/src/pwa.unit.test.ts +321 -0
- package/src/schema.unit.test.ts +122 -0
- package/src/self-serve-config.ts +52 -0
- package/src/self-serve-config.unit.test.ts +79 -0
- package/src/server.smoke.test.ts +273 -0
- package/src/server.ts +50 -0
- package/src/service-integration.ts +67 -0
- package/src/service-integration.unit.test.ts +129 -0
- package/src/slack-provisioning-client.integration.test.ts +308 -0
- package/src/slack-provisioning-client.ts +411 -0
- package/src/slack-provisioning-client.unit.test.ts +80 -0
- package/src/slack-provisioning-service.integration.test.ts +637 -0
- package/src/slack-provisioning-service.ts +451 -0
- package/src/slack-provisioning.integration.test.ts +395 -0
- package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
- package/src/specialty-manifest-loader.ts +255 -0
- package/src/specialty-manifest-loader.unit.test.ts +301 -0
- package/src/specialty-registry.schema.unit.test.ts +35 -0
- package/src/specialty-registry.ts +236 -0
- package/src/specialty-registry.unit.test.ts +272 -0
- package/src/startup-policy-reconcile.ts +77 -0
- package/src/startup-policy-reconcile.unit.test.ts +251 -0
- package/src/theme-tokens.json +12 -0
- package/src/token-crypto.ts +60 -0
- package/src/token-crypto.unit.test.ts +182 -0
- package/src/trial-expiry-sweeper.integration.test.ts +318 -0
- package/src/trial-expiry-sweeper.ts +132 -0
- package/src/trial-expiry-sweeper.unit.test.ts +391 -0
- package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
- package/src/trial-expiry-warning-sweeper.ts +447 -0
- package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
- package/src/voice-contract.unit.test.ts +102 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/anthropic-credential-guard.ts
|
|
3
|
+
* Shared guard for Anthropic workload identity federation (WIF-2.1).
|
|
4
|
+
*
|
|
5
|
+
* CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN each
|
|
6
|
+
* outrank federation in the Anthropic client — even set to an empty string —
|
|
7
|
+
* so with federation enabled none of them may reach a federated (non
|
|
8
|
+
* self-hosted) agent through its env bundle.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { BadRequestError } from "./errors.ts";
|
|
12
|
+
|
|
13
|
+
export const ANTHROPIC_CREDENTIAL_KEYS = [
|
|
14
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
15
|
+
"ANTHROPIC_API_KEY",
|
|
16
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
/** Stable machine-readable code for API/UI callers. */
|
|
20
|
+
export const ANTHROPIC_CREDENTIALS_BLOCKED_CODE =
|
|
21
|
+
"anthropic_credentials_blocked_federation";
|
|
22
|
+
|
|
23
|
+
const BLOCKED_KEYS = new Set<string>(ANTHROPIC_CREDENTIAL_KEYS);
|
|
24
|
+
|
|
25
|
+
export function isAnthropicCredentialKey(key: string): boolean {
|
|
26
|
+
return BLOCKED_KEYS.has(key);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The subset of `keys` that are blocked Anthropic credential keys. */
|
|
30
|
+
export function anthropicCredentialKeysIn(keys: Iterable<string>): string[] {
|
|
31
|
+
return [...keys].filter(isAnthropicCredentialKey);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Thrown (HTTP 400) when a blocked key is written for a federated agent. */
|
|
35
|
+
export class AnthropicCredentialsBlockedError extends BadRequestError {
|
|
36
|
+
readonly code = ANTHROPIC_CREDENTIALS_BLOCKED_CODE;
|
|
37
|
+
|
|
38
|
+
constructor(public readonly keys: string[]) {
|
|
39
|
+
super(
|
|
40
|
+
`${ANTHROPIC_CREDENTIALS_BLOCKED_CODE}: ${keys.join(", ")} cannot be set on an agent that uses Anthropic workload identity federation`,
|
|
41
|
+
);
|
|
42
|
+
this.name = "AnthropicCredentialsBlockedError";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/anthropic-credential-guard.unit.test.ts
|
|
3
|
+
* Unit tests for the Anthropic credential guard (WIF-2.1).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
ANTHROPIC_CREDENTIAL_KEYS,
|
|
9
|
+
ANTHROPIC_CREDENTIALS_BLOCKED_CODE,
|
|
10
|
+
AnthropicCredentialsBlockedError,
|
|
11
|
+
anthropicCredentialKeysIn,
|
|
12
|
+
isAnthropicCredentialKey,
|
|
13
|
+
} from "./anthropic-credential-guard.ts";
|
|
14
|
+
import { BadRequestError } from "./errors.ts";
|
|
15
|
+
|
|
16
|
+
describe("isAnthropicCredentialKey", () => {
|
|
17
|
+
it.each([
|
|
18
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
19
|
+
"ANTHROPIC_API_KEY",
|
|
20
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
21
|
+
])("returns true for %s", (key) => {
|
|
22
|
+
expect(isAnthropicCredentialKey(key)).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it.each(["OPENAI_API_KEY", "ANTHROPIC_BASE_URL", "anthropic_api_key", ""])(
|
|
26
|
+
"returns false for %p",
|
|
27
|
+
(key) => {
|
|
28
|
+
expect(isAnthropicCredentialKey(key)).toBe(false);
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
it("covers every exported credential key", () => {
|
|
33
|
+
for (const key of ANTHROPIC_CREDENTIAL_KEYS) {
|
|
34
|
+
expect(isAnthropicCredentialKey(key)).toBe(true);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("anthropicCredentialKeysIn", () => {
|
|
40
|
+
it("filters to blocked keys, preserving input order", () => {
|
|
41
|
+
expect(
|
|
42
|
+
anthropicCredentialKeysIn([
|
|
43
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
44
|
+
"FOO",
|
|
45
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
46
|
+
"BAR",
|
|
47
|
+
"ANTHROPIC_API_KEY",
|
|
48
|
+
]),
|
|
49
|
+
).toEqual([
|
|
50
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
51
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
52
|
+
"ANTHROPIC_API_KEY",
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns an empty array for empty input", () => {
|
|
57
|
+
expect(anthropicCredentialKeysIn([])).toEqual([]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns an empty array when no keys are blocked", () => {
|
|
61
|
+
expect(anthropicCredentialKeysIn(["FOO", "BAR"])).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("accepts any iterable (e.g. a Set)", () => {
|
|
65
|
+
expect(
|
|
66
|
+
anthropicCredentialKeysIn(new Set(["ANTHROPIC_API_KEY", "FOO"])),
|
|
67
|
+
).toEqual(["ANTHROPIC_API_KEY"]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("AnthropicCredentialsBlockedError", () => {
|
|
72
|
+
it("extends BadRequestError and carries the stable code", () => {
|
|
73
|
+
const err = new AnthropicCredentialsBlockedError(["ANTHROPIC_API_KEY"]);
|
|
74
|
+
expect(err).toBeInstanceOf(BadRequestError);
|
|
75
|
+
expect(err).toBeInstanceOf(Error);
|
|
76
|
+
expect(err.code).toBe("anthropic_credentials_blocked_federation");
|
|
77
|
+
expect(err.code).toBe(ANTHROPIC_CREDENTIALS_BLOCKED_CODE);
|
|
78
|
+
expect(err.name).toBe("AnthropicCredentialsBlockedError");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("lists the keys in the message and exposes them", () => {
|
|
82
|
+
const keys = ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN"];
|
|
83
|
+
const err = new AnthropicCredentialsBlockedError(keys);
|
|
84
|
+
expect(err.keys).toEqual(keys);
|
|
85
|
+
expect(err.message).toContain("ANTHROPIC_API_KEY, CLAUDE_CODE_OAUTH_TOKEN");
|
|
86
|
+
expect(err.message).toContain(ANTHROPIC_CREDENTIALS_BLOCKED_CODE);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/anthropic-federated-token-provider.integration.test.ts
|
|
3
|
+
* FederatedTokenProvider against the FakeAnthropicTokenExchange test double
|
|
4
|
+
* (POST /v1/oauth/token is not in the Anthropic OpenAPI spec, so there is no
|
|
5
|
+
* spec-validated recorded fixture for it — see anthropic-identity-client.fixtures.ts).
|
|
6
|
+
* The projected token is a real temp file; time is an injected Clock.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
AnthropicTokenExchangeError,
|
|
15
|
+
FederatedTokenProvider,
|
|
16
|
+
} from "./anthropic-federated-token-provider.ts";
|
|
17
|
+
import {
|
|
18
|
+
asFetch,
|
|
19
|
+
FakeAnthropicTokenExchange,
|
|
20
|
+
} from "./anthropic-identity-client.fixtures.ts";
|
|
21
|
+
import type { Clock } from "./clock.ts";
|
|
22
|
+
|
|
23
|
+
class MutableClock implements Clock {
|
|
24
|
+
constructor(private ms: number) {}
|
|
25
|
+
now(): Date {
|
|
26
|
+
return new Date(this.ms);
|
|
27
|
+
}
|
|
28
|
+
advanceSeconds(s: number): void {
|
|
29
|
+
this.ms += s * 1000;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let dir: string;
|
|
34
|
+
let tokenFile: string;
|
|
35
|
+
let clock: MutableClock;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
dir = mkdtempSync(join(tmpdir(), "anthropic-token-"));
|
|
39
|
+
tokenFile = join(dir, "token");
|
|
40
|
+
writeFileSync(tokenFile, "projected-jwt-v1\n");
|
|
41
|
+
clock = new MutableClock(Date.parse("2026-10-01T12:00:00Z"));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
rmSync(dir, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function makeProvider(
|
|
49
|
+
exchange: FakeAnthropicTokenExchange,
|
|
50
|
+
extra: Partial<ConstructorParameters<typeof FederatedTokenProvider>[0]> = {},
|
|
51
|
+
) {
|
|
52
|
+
return new FederatedTokenProvider({
|
|
53
|
+
fetchFn: exchange.asFetch(),
|
|
54
|
+
tokenFilePath: tokenFile,
|
|
55
|
+
federationRuleId: "fdrl_01Admin",
|
|
56
|
+
organizationId: "org-1234",
|
|
57
|
+
clock,
|
|
58
|
+
...extra,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("FederatedTokenProvider", () => {
|
|
63
|
+
it("POSTs a jwt-bearer grant with the projected token and returns the access token", async () => {
|
|
64
|
+
const exchange = new FakeAnthropicTokenExchange();
|
|
65
|
+
const provider = makeProvider(exchange, {
|
|
66
|
+
serviceAccountId: "svac_01Admin",
|
|
67
|
+
workspaceId: "wrkspc_01Work",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
expect(await provider.getAccessToken()).toBe("fake-access-token-1");
|
|
71
|
+
|
|
72
|
+
const req = exchange.requests[0];
|
|
73
|
+
expect(req.method).toBe("POST");
|
|
74
|
+
expect(req.url.toString()).toBe("https://api.anthropic.com/v1/oauth/token");
|
|
75
|
+
expect(req.headers.get("content-type")).toBe("application/json");
|
|
76
|
+
expect(req.headers.get("anthropic-beta")).toContain(
|
|
77
|
+
"oidc-federation-2026-04-01",
|
|
78
|
+
);
|
|
79
|
+
expect(req.body).toEqual({
|
|
80
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
81
|
+
assertion: "projected-jwt-v1",
|
|
82
|
+
federation_rule_id: "fdrl_01Admin",
|
|
83
|
+
organization_id: "org-1234",
|
|
84
|
+
service_account_id: "svac_01Admin",
|
|
85
|
+
workspace_id: "wrkspc_01Work",
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("omits optional ids when not configured", async () => {
|
|
90
|
+
const exchange = new FakeAnthropicTokenExchange();
|
|
91
|
+
await makeProvider(exchange).getAccessToken();
|
|
92
|
+
expect(Object.keys(exchange.requests[0].body as object).sort()).toEqual([
|
|
93
|
+
"assertion",
|
|
94
|
+
"federation_rule_id",
|
|
95
|
+
"grant_type",
|
|
96
|
+
"organization_id",
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("caches the access token until shortly before expires_in", async () => {
|
|
101
|
+
const exchange = new FakeAnthropicTokenExchange({ expiresIn: 3600 });
|
|
102
|
+
const provider = makeProvider(exchange);
|
|
103
|
+
|
|
104
|
+
await provider.getAccessToken();
|
|
105
|
+
clock.advanceSeconds(3600 - 61);
|
|
106
|
+
expect(await provider.getAccessToken()).toBe("fake-access-token-1");
|
|
107
|
+
expect(exchange.calls).toBe(1);
|
|
108
|
+
|
|
109
|
+
clock.advanceSeconds(2);
|
|
110
|
+
expect(await provider.getAccessToken()).toBe("fake-access-token-2");
|
|
111
|
+
expect(exchange.calls).toBe(2);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("re-reads the projected token file on every exchange (kubelet rotation)", async () => {
|
|
115
|
+
const exchange = new FakeAnthropicTokenExchange({ expiresIn: 120 });
|
|
116
|
+
const provider = makeProvider(exchange);
|
|
117
|
+
|
|
118
|
+
await provider.getAccessToken();
|
|
119
|
+
writeFileSync(tokenFile, "projected-jwt-v2\n");
|
|
120
|
+
clock.advanceSeconds(200);
|
|
121
|
+
await provider.getAccessToken();
|
|
122
|
+
|
|
123
|
+
expect(exchange.assertions).toEqual([
|
|
124
|
+
"projected-jwt-v1",
|
|
125
|
+
"projected-jwt-v2",
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("invalidate() forces a fresh exchange", async () => {
|
|
130
|
+
const exchange = new FakeAnthropicTokenExchange();
|
|
131
|
+
const provider = makeProvider(exchange);
|
|
132
|
+
await provider.getAccessToken();
|
|
133
|
+
provider.invalidate();
|
|
134
|
+
expect(await provider.getAccessToken()).toBe("fake-access-token-2");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("coalesces concurrent callers into one exchange", async () => {
|
|
138
|
+
const exchange = new FakeAnthropicTokenExchange();
|
|
139
|
+
const provider = makeProvider(exchange);
|
|
140
|
+
const tokens = await Promise.all([
|
|
141
|
+
provider.getAccessToken(),
|
|
142
|
+
provider.getAccessToken(),
|
|
143
|
+
provider.getAccessToken(),
|
|
144
|
+
]);
|
|
145
|
+
expect(new Set(tokens).size).toBe(1);
|
|
146
|
+
expect(exchange.calls).toBe(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("fails clearly, without file contents, when the token file is missing", async () => {
|
|
150
|
+
rmSync(tokenFile);
|
|
151
|
+
const provider = makeProvider(new FakeAnthropicTokenExchange());
|
|
152
|
+
const err = await provider.getAccessToken().catch((e) => e);
|
|
153
|
+
expect(err).toBeInstanceOf(AnthropicTokenExchangeError);
|
|
154
|
+
expect(err.message).toMatch(/projected token file/i);
|
|
155
|
+
expect(err.message).toContain(tokenFile);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("fails when the token file is empty", async () => {
|
|
159
|
+
writeFileSync(tokenFile, " \n");
|
|
160
|
+
const provider = makeProvider(new FakeAnthropicTokenExchange());
|
|
161
|
+
await expect(provider.getAccessToken()).rejects.toThrow(/empty/i);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("surfaces status, error code and request-id on exchange failure but never echoed secrets", async () => {
|
|
165
|
+
const exchange = new FakeAnthropicTokenExchange({
|
|
166
|
+
failWith: {
|
|
167
|
+
status: 401,
|
|
168
|
+
body: {
|
|
169
|
+
error: "invalid_grant",
|
|
170
|
+
error_description: "no matching rule",
|
|
171
|
+
assertion: "projected-jwt-v1",
|
|
172
|
+
access_token: "echoed-access-token",
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
const err = await makeProvider(exchange)
|
|
177
|
+
.getAccessToken()
|
|
178
|
+
.catch((e) => e);
|
|
179
|
+
expect(err).toBeInstanceOf(AnthropicTokenExchangeError);
|
|
180
|
+
expect(err.status).toBe(401);
|
|
181
|
+
expect(err.code).toBe("invalid_grant");
|
|
182
|
+
expect(err.message).toContain("401");
|
|
183
|
+
expect(err.message).toContain("req_fake");
|
|
184
|
+
expect(err.message).not.toContain("projected-jwt-v1");
|
|
185
|
+
expect(err.message).not.toContain("echoed-access-token");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("rejects a success body that fails schema validation without echoing it", async () => {
|
|
189
|
+
const exchange = new FakeAnthropicTokenExchange({
|
|
190
|
+
successBody: () => ({ access_token: "", leaked: "do-not-print" }),
|
|
191
|
+
});
|
|
192
|
+
const err = await makeProvider(exchange)
|
|
193
|
+
.getAccessToken()
|
|
194
|
+
.catch((e) => e);
|
|
195
|
+
expect(err).toBeInstanceOf(AnthropicTokenExchangeError);
|
|
196
|
+
expect(err.message).not.toContain("do-not-print");
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("rejects a non-bearer token_type", async () => {
|
|
200
|
+
const exchange = new FakeAnthropicTokenExchange({
|
|
201
|
+
successBody: () => ({
|
|
202
|
+
access_token: "fake-access-token",
|
|
203
|
+
token_type: "mac",
|
|
204
|
+
expires_in: 60,
|
|
205
|
+
}),
|
|
206
|
+
});
|
|
207
|
+
await expect(makeProvider(exchange).getAccessToken()).rejects.toThrow(
|
|
208
|
+
/token_type/,
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("rejects a non-JSON response", async () => {
|
|
213
|
+
const fetchFn = asFetch(
|
|
214
|
+
async () => new Response("<html>oops</html>", { status: 200 }),
|
|
215
|
+
);
|
|
216
|
+
const provider = new FederatedTokenProvider({
|
|
217
|
+
fetchFn,
|
|
218
|
+
tokenFilePath: tokenFile,
|
|
219
|
+
federationRuleId: "fdrl_01Admin",
|
|
220
|
+
organizationId: "org-1234",
|
|
221
|
+
clock,
|
|
222
|
+
});
|
|
223
|
+
await expect(provider.getAccessToken()).rejects.toThrow(/not valid JSON/);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("wraps network failures without leaking the assertion", async () => {
|
|
227
|
+
const fetchFn = asFetch(async () => {
|
|
228
|
+
throw new Error("connect ECONNREFUSED projected-jwt-v1");
|
|
229
|
+
});
|
|
230
|
+
const provider = new FederatedTokenProvider({
|
|
231
|
+
fetchFn,
|
|
232
|
+
tokenFilePath: tokenFile,
|
|
233
|
+
federationRuleId: "fdrl_01Admin",
|
|
234
|
+
organizationId: "org-1234",
|
|
235
|
+
clock,
|
|
236
|
+
});
|
|
237
|
+
const err = await provider.getAccessToken().catch((e) => e);
|
|
238
|
+
expect(err).toBeInstanceOf(AnthropicTokenExchangeError);
|
|
239
|
+
expect(err.message).not.toContain("projected-jwt-v1");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("refuses to send the assertion to a non-https, non-loopback base URL", async () => {
|
|
243
|
+
const exchange = new FakeAnthropicTokenExchange();
|
|
244
|
+
const provider = makeProvider(exchange, { baseUrl: "http://example.com" });
|
|
245
|
+
await expect(provider.getAccessToken()).rejects.toThrow(/https/);
|
|
246
|
+
expect(exchange.calls).toBe(0);
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* admin/src/anthropic-federated-token-provider.ts
|
|
3
|
+
* Signs the admin pod in to the Anthropic API by workload identity federation.
|
|
4
|
+
*
|
|
5
|
+
* The pod's Kubernetes projected ServiceAccount token (a JWT at a configurable
|
|
6
|
+
* file path, rotated by the kubelet) is exchanged for a short-lived Anthropic
|
|
7
|
+
* access token via POST /v1/oauth/token. The wire format mirrors what the
|
|
8
|
+
* Anthropic SDK itself sends for federation: an RFC 7523 jwt-bearer grant that
|
|
9
|
+
* names the federation rule and organization.
|
|
10
|
+
*
|
|
11
|
+
* KNOWN GAP: POST /v1/oauth/token is not in the Anthropic OpenAPI spec that the
|
|
12
|
+
* rest of this client's fixtures are validated against, so this exchange is
|
|
13
|
+
* covered only by the FakeAnthropicTokenExchange test double
|
|
14
|
+
* (anthropic-identity-client.fixtures.ts).
|
|
15
|
+
*
|
|
16
|
+
* Secrets: neither the projected token nor the access token is ever logged or
|
|
17
|
+
* placed in an error message. Error bodies from the token endpoint are reduced
|
|
18
|
+
* to the RFC 6749 section 5.2 `error` code; everything else (which may echo
|
|
19
|
+
* the assertion) is dropped.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFile } from "node:fs/promises";
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
import { type Clock, SystemClock } from "./clock.ts";
|
|
25
|
+
|
|
26
|
+
export const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
|
27
|
+
const TOKEN_PATH = "/v1/oauth/token";
|
|
28
|
+
const GRANT_TYPE_JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
|
29
|
+
export const OAUTH_API_BETA = "oauth-2025-04-20";
|
|
30
|
+
const FEDERATION_BETA = "oidc-federation-2026-04-01";
|
|
31
|
+
/** Token endpoint rejects assertions over 16 KiB. */
|
|
32
|
+
const MAX_ASSERTION_BYTES = 16 * 1024;
|
|
33
|
+
/** Refresh this long before the token actually expires. */
|
|
34
|
+
const DEFAULT_REFRESH_SKEW_SECONDS = 60;
|
|
35
|
+
|
|
36
|
+
export interface AnthropicAccessTokenProvider {
|
|
37
|
+
/** A currently valid access token (exchanged on demand, cached). */
|
|
38
|
+
getAccessToken(): Promise<string>;
|
|
39
|
+
/** Drop the cached token, e.g. after the API answered 401. */
|
|
40
|
+
invalidate(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class AnthropicTokenExchangeError extends Error {
|
|
44
|
+
constructor(
|
|
45
|
+
message: string,
|
|
46
|
+
readonly status?: number,
|
|
47
|
+
readonly code?: string,
|
|
48
|
+
readonly requestId?: string,
|
|
49
|
+
) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = "AnthropicTokenExchangeError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface FederatedTokenProviderOptions {
|
|
56
|
+
fetchFn: typeof fetch;
|
|
57
|
+
/** Path of the projected ServiceAccount token file. */
|
|
58
|
+
tokenFilePath: string;
|
|
59
|
+
federationRuleId: string;
|
|
60
|
+
organizationId: string;
|
|
61
|
+
serviceAccountId?: string;
|
|
62
|
+
workspaceId?: string;
|
|
63
|
+
baseUrl?: string;
|
|
64
|
+
clock?: Clock;
|
|
65
|
+
refreshSkewSeconds?: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const TokenResponse = z.object({
|
|
69
|
+
access_token: z.string().min(1),
|
|
70
|
+
token_type: z.string().optional(),
|
|
71
|
+
expires_in: z.number().positive(),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const TokenErrorBody = z.object({ error: z.string() });
|
|
75
|
+
|
|
76
|
+
function requireSecureBaseUrl(baseUrl: string): void {
|
|
77
|
+
let u: URL;
|
|
78
|
+
try {
|
|
79
|
+
u = new URL(baseUrl);
|
|
80
|
+
} catch {
|
|
81
|
+
throw new AnthropicTokenExchangeError(
|
|
82
|
+
"Anthropic base URL is not a valid URL",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const host = u.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
86
|
+
const loopback =
|
|
87
|
+
host === "localhost" || host === "127.0.0.1" || host === "::1";
|
|
88
|
+
if (u.protocol !== "https:" && !(u.protocol === "http:" && loopback)) {
|
|
89
|
+
throw new AnthropicTokenExchangeError(
|
|
90
|
+
"Refusing to send a credential to a non-https Anthropic token endpoint",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class FederatedTokenProvider implements AnthropicAccessTokenProvider {
|
|
96
|
+
private readonly clock: Clock;
|
|
97
|
+
private readonly baseUrl: string;
|
|
98
|
+
private readonly skewMs: number;
|
|
99
|
+
private cached: { token: string; refreshAtMs: number } | null = null;
|
|
100
|
+
private inflight: Promise<string> | null = null;
|
|
101
|
+
private generation = 0;
|
|
102
|
+
|
|
103
|
+
constructor(private readonly opts: FederatedTokenProviderOptions) {
|
|
104
|
+
this.clock = opts.clock ?? SystemClock();
|
|
105
|
+
this.baseUrl = (opts.baseUrl ?? DEFAULT_ANTHROPIC_BASE_URL).replace(
|
|
106
|
+
/\/+$/,
|
|
107
|
+
"",
|
|
108
|
+
);
|
|
109
|
+
this.skewMs =
|
|
110
|
+
(opts.refreshSkewSeconds ?? DEFAULT_REFRESH_SKEW_SECONDS) * 1000;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async getAccessToken(): Promise<string> {
|
|
114
|
+
if (this.cached && this.clock.now().getTime() < this.cached.refreshAtMs) {
|
|
115
|
+
return this.cached.token;
|
|
116
|
+
}
|
|
117
|
+
if (!this.inflight) {
|
|
118
|
+
const generation = this.generation;
|
|
119
|
+
const pending = this.exchange(generation).finally(() => {
|
|
120
|
+
if (this.inflight === pending) this.inflight = null;
|
|
121
|
+
});
|
|
122
|
+
this.inflight = pending;
|
|
123
|
+
}
|
|
124
|
+
return this.inflight;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
invalidate(): void {
|
|
128
|
+
this.cached = null;
|
|
129
|
+
this.generation++;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private async readProjectedToken(): Promise<string> {
|
|
133
|
+
let raw: string;
|
|
134
|
+
try {
|
|
135
|
+
raw = await readFile(this.opts.tokenFilePath, "utf-8");
|
|
136
|
+
} catch (err) {
|
|
137
|
+
const code = (err as { code?: string }).code ?? "unknown";
|
|
138
|
+
throw new AnthropicTokenExchangeError(
|
|
139
|
+
`Failed to read projected token file at ${this.opts.tokenFilePath} (${code})`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const jwt = raw.trim();
|
|
143
|
+
if (!jwt) {
|
|
144
|
+
throw new AnthropicTokenExchangeError(
|
|
145
|
+
`Projected token file at ${this.opts.tokenFilePath} is empty`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if (Buffer.byteLength(jwt) > MAX_ASSERTION_BYTES) {
|
|
149
|
+
throw new AnthropicTokenExchangeError(
|
|
150
|
+
"Projected token exceeds the 16 KiB assertion limit",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return jwt;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async exchange(generation: number): Promise<string> {
|
|
157
|
+
requireSecureBaseUrl(this.baseUrl);
|
|
158
|
+
// Re-read on every exchange: the kubelet rotates the projected token.
|
|
159
|
+
const assertion = await this.readProjectedToken();
|
|
160
|
+
|
|
161
|
+
const body: Record<string, string> = {
|
|
162
|
+
grant_type: GRANT_TYPE_JWT_BEARER,
|
|
163
|
+
assertion,
|
|
164
|
+
federation_rule_id: this.opts.federationRuleId,
|
|
165
|
+
organization_id: this.opts.organizationId,
|
|
166
|
+
};
|
|
167
|
+
if (this.opts.serviceAccountId) {
|
|
168
|
+
body.service_account_id = this.opts.serviceAccountId;
|
|
169
|
+
}
|
|
170
|
+
if (this.opts.workspaceId) body.workspace_id = this.opts.workspaceId;
|
|
171
|
+
|
|
172
|
+
let resp: Response;
|
|
173
|
+
try {
|
|
174
|
+
resp = await this.opts.fetchFn(`${this.baseUrl}${TOKEN_PATH}`, {
|
|
175
|
+
method: "POST",
|
|
176
|
+
redirect: "manual",
|
|
177
|
+
headers: {
|
|
178
|
+
"Content-Type": "application/json",
|
|
179
|
+
"anthropic-beta": `${OAUTH_API_BETA},${FEDERATION_BETA}`,
|
|
180
|
+
},
|
|
181
|
+
body: JSON.stringify(body),
|
|
182
|
+
});
|
|
183
|
+
} catch {
|
|
184
|
+
// The underlying error text could echo request details; drop it.
|
|
185
|
+
throw new AnthropicTokenExchangeError(
|
|
186
|
+
"Anthropic token exchange request failed (network error)",
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const requestId = resp.headers.get("Request-Id") ?? undefined;
|
|
191
|
+
const text = await resp.text().catch(() => "");
|
|
192
|
+
let json: unknown;
|
|
193
|
+
try {
|
|
194
|
+
json = JSON.parse(text);
|
|
195
|
+
} catch {
|
|
196
|
+
json = undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!resp.ok) {
|
|
200
|
+
const parsed = TokenErrorBody.safeParse(json);
|
|
201
|
+
const code = parsed.success ? parsed.data.error : undefined;
|
|
202
|
+
throw new AnthropicTokenExchangeError(
|
|
203
|
+
`Anthropic token exchange failed with status ${resp.status}${
|
|
204
|
+
code ? ` (${code})` : ""
|
|
205
|
+
}${requestId ? ` (request-id ${requestId})` : ""}`,
|
|
206
|
+
resp.status,
|
|
207
|
+
code,
|
|
208
|
+
requestId,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (json === undefined) {
|
|
213
|
+
throw new AnthropicTokenExchangeError(
|
|
214
|
+
"Anthropic token exchange response was not valid JSON",
|
|
215
|
+
resp.status,
|
|
216
|
+
undefined,
|
|
217
|
+
requestId,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const parsed = TokenResponse.safeParse(json);
|
|
221
|
+
if (!parsed.success) {
|
|
222
|
+
throw new AnthropicTokenExchangeError(
|
|
223
|
+
"Anthropic token exchange response failed schema validation",
|
|
224
|
+
resp.status,
|
|
225
|
+
undefined,
|
|
226
|
+
requestId,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
const { access_token, token_type, expires_in } = parsed.data;
|
|
230
|
+
if (token_type && token_type.toLowerCase() !== "bearer") {
|
|
231
|
+
throw new AnthropicTokenExchangeError(
|
|
232
|
+
"Anthropic token exchange returned an unsupported token_type",
|
|
233
|
+
resp.status,
|
|
234
|
+
undefined,
|
|
235
|
+
requestId,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// A token minted before invalidate() was called is not cached.
|
|
240
|
+
if (generation === this.generation) {
|
|
241
|
+
this.cached = {
|
|
242
|
+
token: access_token,
|
|
243
|
+
refreshAtMs:
|
|
244
|
+
this.clock.now().getTime() + expires_in * 1000 - this.skewMs,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return access_token;
|
|
248
|
+
}
|
|
249
|
+
}
|