diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e22443d..dcd18204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,22 @@ Both `Agent365.Observability.OtelWrite` (Delegated) and `Agent365.Observability. ### Breaking Changes (`@microsoft/agents-a365-observability`) +- **OBS exports always use `/observabilityService`** - The `useS2SEndpoint` option is + deprecated and ignored, even when `false`. Batch and per-request exports no longer + select or fall back to `/observability`. Provide an app-only OBS token independently + of your agent's workload auth; the S2S service rejects delegated `scp` tokens. +- **Per-request OBS requires the configured app-only resolver** - Both export modes + use `withTokenResolver(...)` or `exporterOptions.tokenResolver`, with the builder + method taking precedence. `Agent365Exporter` no longer reads tokens from + `runWithExportToken`/`updateExportToken`. Missing resolvers fail configuration; + empty tokens or acquisition failures fail export without delegated fallback. + The exporter invokes the resolver on every export batch, so resolvers must + cache the acquired token and refresh only near expiry. + Workload OBO and custom-exporter context helpers are otherwise unchanged. +- **Hosting OBS token cache requires an app-only resolver** - + `RefreshObservabilityToken(agentId, tenantId, tokenResolver)` replaces the + `TurnContext`/`Authorization` overload, which now throws without exchanging a + user token. Token acquisition failures propagate instead of appearing successful. - **`InvokeAgentDetails` renamed to `InvokeAgentScopeDetails`** — Now contains only scope-level config (`endpoint`). Agent identity (`AgentDetails`) is a separate parameter. `sessionId` moved to `Request`. - **`InvokeAgentScope.start()` — new signature.** `start(request, invokeScopeDetails, agentDetails, callerDetails?, spanDetails?)`. Tenant ID is derived from `agentDetails.tenantId` (required). `userDetails` and `callerAgentDetails` are wrapped in `CallerDetails`. Span options grouped in `SpanDetails`. - **`InferenceScope.start()` — new signature.** `start(request, details, agentDetails, userDetails?, spanDetails?)`. Tenant ID derived from `agentDetails.tenantId` (required). diff --git a/packages/agents-a365-observability-hosting/docs/design.md b/packages/agents-a365-observability-hosting/docs/design.md index c422f227..b501d8f2 100644 --- a/packages/agents-a365-observability-hosting/docs/design.md +++ b/packages/agents-a365-observability-hosting/docs/design.md @@ -107,18 +107,59 @@ const agentPairs = getTargetAgentBaggagePairs(turnContext); ### AgenticTokenCacheInstance ([AgenticTokenCache.ts](../src/caching/AgenticTokenCache.ts)) -Token caching for improved performance: +Cache app-only OBS tokens independently of the workload's AI Teammate or OBO +authorization. The former overload accepting `TurnContext` and `Authorization` +now throws rather than acquiring a delegated token incompatible with S2S. ```typescript import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-hosting'; -// Cache token with key -AgenticTokenCacheInstance.set('cache-key', 'token-value', ttlMs); +// acquireAppOnlyObsToken is your app-only token acquisition callback. +// It receives (agentId, tenantId, scopes) and returns the final OBS access token. +await AgenticTokenCacheInstance.RefreshObservabilityToken( + agentId, tenantId, acquireAppOnlyObsToken +); -// Retrieve cached token -const token = AgenticTokenCacheInstance.get('cache-key'); +const token = AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId); ``` +For a blueprint-backed agent, acquire a blueprint exchange assertion with +`fmi_path=agentId`, then use it as `client_assertion` in an instance +`client_credentials` request for the OBS `/.default` scope. Do not send the +intermediate assertion, a blueprint token, or a `user_fic`/OBO token to OBS. +The final token's application identity must match `agentId`, its tenant must +match `tenantId`, and its audience must be OBS. An eligible Agent 365-registered +instance can use a roleless app token when service policy permits; an +`Agent365.Observability.OtelWrite` grant is not a universal prerequisite. Entra +identity creation alone does not establish instance registration or service access. +The resolver must validate app-only identity (explicit `idtyp=app` for a roleless +token), reject delegated `scp` tokens, and check audience and lifetime before +returning a token. The cache does not perform token authentication or authorization. +Acquisition failures propagate to the caller and never trigger delegated authentication. + +When migrating, replace only the OBS refresh call, not workload MCP/Graph/OBO +authorization. Configure an OBS resolver in both batch and per-request modes. +It should refresh the app-only cache at export time before returning its token, +so long-running requests do not depend on a token acquired at turn start: + +```typescript +builder.withTokenResolver(async (agentId, tenantId) => { + await AgenticTokenCacheInstance.RefreshObservabilityToken( + agentId, tenantId, acquireAppOnlyObsToken + ); + return AgenticTokenCacheInstance.getObservabilityToken(agentId, tenantId); +}); +``` + +`Agent365Exporter` ignores tokens in `runWithExportToken`; those context helpers +remain available for custom exporters, not as an OBS authentication fallback. +An enabled exporter without an explicit resolver fails configuration. +Both modes use the S2S OTLP +route even if the deprecated `useS2SEndpoint` option is false. Missing tokens and +failed acquisition report export failure without sending a request; HTTP +401/403/404 never select an OBO fallback. Check instance registration and service +policy rather than adding OBS permissions automatically. + ## Tenant ID Resolution The package extracts tenant ID from multiple sources: diff --git a/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts b/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts index 62a4b3d7..0d518b7d 100644 --- a/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts +++ b/packages/agents-a365-observability-hosting/src/caching/AgenticTokenCache.ts @@ -3,9 +3,19 @@ // Licensed under the MIT License. // ------------------------------------------------------------------------------ -import { TurnContext, Authorization } from '@microsoft/agents-hosting'; -import { logger, formatError, ObservabilityConfiguration, defaultObservabilityConfigurationProvider } from '@microsoft/agents-a365-observability'; -import { IConfigurationProvider } from '@microsoft/agents-a365-runtime'; +import type { TurnContext, Authorization } from '@microsoft/agents-hosting'; +import { + logger, formatError, defaultObservabilityConfigurationProvider, + type ObservabilityConfiguration, type TokenResolver, +} from '@microsoft/agents-a365-observability'; +import type { IConfigurationProvider } from '@microsoft/agents-a365-runtime'; + +/** Acquires an app-only OBS token; must not perform user_fic or OBO authentication. */ +export type ObservabilityTokenResolver = ( + agentId: string, + tenantId: string, + scopes: readonly string[] +) => ReturnType; interface CacheEntry { scopes: string[]; @@ -64,28 +74,48 @@ export class AgenticTokenCache { return entry.token; } + /** + * Refreshes an app-only OBS token independently of the current user's authorization. + * The resolver receives the configured OBS scopes and must acquire a token for + * the exporting agent identity, not its blueprint or the workload's user. + */ + public async RefreshObservabilityToken( + agentId: string, + tenantId: string, + tokenResolver: ObservabilityTokenResolver + ): Promise; + + /** @deprecated User token exchange cannot authenticate S2S OBS. Pass an app-only token resolver instead. */ public async RefreshObservabilityToken( agentId: string, tenantId: string, turnContext: TurnContext, authorization: Authorization, scopes: string[], - authHandlerName: string = 'agentic' + authHandlerName?: string + ): Promise; + + public async RefreshObservabilityToken( + agentId: string, + tenantId: string, + resolverOrContext: ObservabilityTokenResolver | TurnContext, + _authorization?: Authorization, + _scopes?: string[], + _authHandlerName?: string ): Promise { - const key = AgenticTokenCache.makeKey(agentId, tenantId); - if (!authorization) { - throw new Error('[AgenticTokenCache] Authorization not set'); + if (typeof resolverOrContext !== 'function') { + throw new Error('[AgenticTokenCache] S2S OBS requires an app-only token resolver. Use RefreshObservabilityToken(agentId, tenantId, tokenResolver); delegated user token exchange is no longer supported.'); } - if (!turnContext) { - throw new Error('[AgenticTokenCache] TurnContext not set'); + if (!agentId?.trim() || !tenantId?.trim()) { + throw new Error('[AgenticTokenCache] Agent and tenant IDs are required'); } + const key = AgenticTokenCache.makeKey(agentId, tenantId); return this.withKeyLock(key, async () => { let entry = this._map.get(key); if (!entry) { - const effectiveScopes = (scopes && scopes.length > 0) ? scopes : [...this._configProvider.getConfiguration().observabilityAuthenticationScopes]; + const effectiveScopes = [...this._configProvider.getConfiguration().observabilityAuthenticationScopes]; if (!Array.isArray(effectiveScopes) || effectiveScopes.length === 0) { - logger.error('[AgenticTokenCache] No valid scopes'); - return; + throw new Error('[AgenticTokenCache] No valid scopes'); } entry = { scopes: effectiveScopes }; if (this._map.size >= this._maxCacheSize) { @@ -97,8 +127,7 @@ export class AgenticTokenCache { this._map.set(key, entry); } if (!Array.isArray(entry.scopes) || entry.scopes.length === 0) { - logger.error('[AgenticTokenCache] Entry has invalid scopes'); - return; + throw new Error('[AgenticTokenCache] Entry has invalid scopes'); } if (entry.token && !this.isExpired(entry)) { @@ -107,21 +136,19 @@ export class AgenticTokenCache { const maxRetries = 2; for (let attempt = 0; attempt <= maxRetries; attempt++) { - logger.info(`[AgenticTokenCache] Exchanging token attempt ${attempt + 1}/${maxRetries + 1}`); + logger.info(`[AgenticTokenCache] Acquiring app-only token attempt ${attempt + 1}/${maxRetries + 1}`); try { - const tokenResponse = await authorization.exchangeToken(turnContext, authHandlerName, { scopes: entry.scopes }); - if (!tokenResponse?.token) { - logger.error('[AgenticTokenCache] Undefined token returned'); - entry.token = undefined; - entry.expiresOn = undefined; - break; + const token = await resolverOrContext(agentId, tenantId, [...entry.scopes]); + if (!token?.trim()) { + throw new Error('[AgenticTokenCache] App-only token resolver returned no token'); } - entry.token = tokenResponse.token; + entry.token = token; entry.acquiredOn = Date.now(); - const oboExp = this.decodeExp(entry.token); - if (oboExp) { - entry.expiresOn = oboExp * 1000; + const exp = this.decodeExp(token); + if (exp) { + entry.expiresOn = exp * 1000; } else { + entry.expiresOn = undefined; logger.warn('[AgenticTokenCache] No exp claim, fallback TTL'); } logger.info('[AgenticTokenCache] Token cached'); @@ -136,7 +163,8 @@ export class AgenticTokenCache { logger.error('[AgenticTokenCache] Non-retriable failure', formatError(e)); entry.token = undefined; entry.expiresOn = undefined; - break; + entry.acquiredOn = undefined; + throw e; } } }); diff --git a/packages/agents-a365-observability-hosting/src/index.ts b/packages/agents-a365-observability-hosting/src/index.ts index ed0173a3..76943828 100644 --- a/packages/agents-a365-observability-hosting/src/index.ts +++ b/packages/agents-a365-observability-hosting/src/index.ts @@ -7,6 +7,7 @@ export * from './utils/BaggageBuilderUtils'; export * from './utils/ScopeUtils'; export * from './utils/TurnContextUtils'; export { AgenticTokenCache, AgenticTokenCacheInstance } from './caching/AgenticTokenCache'; +export type { ObservabilityTokenResolver } from './caching/AgenticTokenCache'; export { BaggageMiddleware } from './middleware/BaggageMiddleware'; export { OutputLoggingMiddleware, A365_PARENT_SPAN_KEY, A365_AUTH_TOKEN_KEY } from './middleware/OutputLoggingMiddleware'; export { ObservabilityHostingManager } from './middleware/ObservabilityHostingManager'; diff --git a/packages/agents-a365-observability/README.md b/packages/agents-a365-observability/README.md index 5ddb1410..869ca0ca 100644 --- a/packages/agents-a365-observability/README.md +++ b/packages/agents-a365-observability/README.md @@ -15,6 +15,65 @@ npm install @microsoft/agents-a365-observability For detailed usage examples and implementation guidance, see the [Microsoft Agent 365 Observability Documentation](https://learn.microsoft.com/microsoft-agent-365/developer/observability?tabs=nodejs). +### OBS endpoint + +All exports use `/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1`, +including batch and per-request exports from AI Teammate and OBO workloads. The exporter +never falls back to `/observability`. The `useS2SEndpoint` option is deprecated and ignored, +including when set to `false`; domain overrides change the host, not this route. + +Endpoint selection does not acquire or convert tokens. Supply an **app-only** OBS +resolver for the exporting tenant and agent identity. Eligible Agent 365-registered +instances can use roleless app tokens when service policy permits; an +`Agent365.Observability.OtelWrite` grant is not a universal prerequisite. +The S2S service rejects delegated (`scp`) tokens, including +AI Teammate user tokens. Keep workload authentication +(such as OBO for MCP or Microsoft Graph) separate from OBS authentication. An authorization +failure is not a reason to retry telemetry on the OBO route. + +When using the hosting token cache, call +`RefreshObservabilityToken(agentId, tenantId, appOnlyTokenResolver)`. The old +`TurnContext`/`Authorization` overload throws rather than acquiring a delegated OBS token. +S2S ingestion may remove unverified user attribution; routing a workload through S2S +does not establish that its caller identity is trusted. + +### Migrating per-request authentication + +Batch and per-request exports both call the configured `tokenResolver` with the +exporting agent and tenant IDs. Configure it with `withTokenResolver(...)` or +`exporterOptions.tokenResolver`; the explicit builder method takes precedence. +The callback must acquire or refresh an app-only OBS token independently of +workload authentication. + +```typescript +import { ObservabilityManager, type TokenResolver } from '@microsoft/agents-a365-observability'; + +function startObservability(resolveAppOnlyObsToken: TokenResolver): void { + ObservabilityManager.configure(builder => { + builder.withService('my-agent').withTokenResolver(resolveAppOnlyObsToken); + }).start(); +} +``` + +Enabling `ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT` changes span buffering, +not credential selection. `runWithExportToken`, `updateExportToken`, and +`getExportToken` remain available for custom export integrations, but +`Agent365Exporter` never uses their context token, even when it looks app-only. +Existing callers must provide the OBS resolver instead of relying on a workload +token in context. + +**Resolvers must cache.** The exporter invokes the resolver on every export +batch, and once per identity group when spans partition across tenants or +agents. In per-request mode that is roughly one call per request. Resolvers +should cache the acquired app-only token and refresh only as it approaches +expiry. `AgenticTokenCache` in `@microsoft/agents-a365-observability-hosting` +implements this pattern; the sample `observability-token-service.ts` files show +a minimal single-identity variant. + +An enabled Agent 365 exporter without a resolver fails configuration. A resolver +failure or empty token fails export without an HTTP request or delegated fallback. +Console-only configuration does not require an OBS resolver. + ## Support For issues, questions, or feedback: diff --git a/packages/agents-a365-observability/docs/design.md b/packages/agents-a365-observability/docs/design.md index 7d947eb3..53d30bf6 100644 --- a/packages/agents-a365-observability/docs/design.md +++ b/packages/agents-a365-observability/docs/design.md @@ -47,7 +47,7 @@ import { ObservabilityManager } from '@microsoft/agents-a365-observability'; ObservabilityManager.start({ serviceName: 'my-agent', serviceVersion: '1.0.0', - tokenResolver: async (agentId, tenantId) => getAuthToken(), + tokenResolver: async (agentId, tenantId) => getAppOnlyObsToken(agentId, tenantId), clusterCategory: 'prod' }); @@ -66,6 +66,12 @@ const instance = ObservabilityManager.getInstance(); await ObservabilityManager.shutdown(); ``` +The configured app-only OBS resolver is required in both batch and per-request +modes. The builder merges resolver options consistently, with `withTokenResolver` +taking precedence over `exporterOptions.tokenResolver`. Request context is retained +for tracing, but its token is not consumed by `Agent365Exporter`. See the +[per-request migration guide](../README.md#migrating-per-request-authentication). + ### ObservabilityBuilder ([ObservabilityBuilder.ts](../src/ObservabilityBuilder.ts)) Fluent API for configuring telemetry: diff --git a/packages/agents-a365-observability/src/ObservabilityBuilder.ts b/packages/agents-a365-observability/src/ObservabilityBuilder.ts index d09484d2..9150b338 100644 --- a/packages/agents-a365-observability/src/ObservabilityBuilder.ts +++ b/packages/agents-a365-observability/src/ObservabilityBuilder.ts @@ -150,12 +150,7 @@ export class ObservabilityBuilder { return this; } - private createBatchProcessor(): BatchSpanProcessor { - if (!isAgent365ExporterEnabled(this.options.configProvider)) { - logger.info('[ObservabilityBuilder] Agent 365 exporter not enabled. Using ConsoleSpanExporter for BatchSpanProcessor.'); - return new BatchSpanProcessor(new ConsoleSpanExporter()); - } - + private createExporterOptions(): Agent365ExporterOptions { const opts = new Agent365ExporterOptions(); if (this.options.exporterOptions) { Object.assign(opts, this.options.exporterOptions); @@ -164,6 +159,16 @@ export class ObservabilityBuilder { if (this.options.tokenResolver) { opts.tokenResolver = this.options.tokenResolver; } + return opts; + } + + private createBatchProcessor(): BatchSpanProcessor { + if (!isAgent365ExporterEnabled(this.options.configProvider)) { + logger.info('[ObservabilityBuilder] Agent 365 exporter not enabled. Using ConsoleSpanExporter for BatchSpanProcessor.'); + return new BatchSpanProcessor(new ConsoleSpanExporter()); + } + + const opts = this.createExporterOptions(); return new BatchSpanProcessor(new Agent365Exporter(opts, this.options.configProvider), { maxQueueSize: opts.maxQueueSize, scheduledDelayMillis: opts.scheduledDelayMilliseconds, @@ -178,14 +183,7 @@ export class ObservabilityBuilder { return new PerRequestSpanProcessor(new ConsoleSpanExporter()); } - const opts = new Agent365ExporterOptions(); - if (this.options.exporterOptions) { - Object.assign(opts, this.options.exporterOptions); - } - opts.clusterCategory = this.options.clusterCategory || opts.clusterCategory || ClusterCategory.prod; - - // For per-request export, token is retrieved from OTel Context by Agent365Exporter - // using getExportToken(), so no tokenResolver is needed here + const opts = this.createExporterOptions(); return new PerRequestSpanProcessor(new Agent365Exporter(opts, this.options.configProvider)); } diff --git a/packages/agents-a365-observability/src/index.ts b/packages/agents-a365-observability/src/index.ts index b1f78544..ad00731d 100644 --- a/packages/agents-a365-observability/src/index.ts +++ b/packages/agents-a365-observability/src/index.ts @@ -5,6 +5,7 @@ export { ObservabilityManager } from './ObservabilityManager'; export { ObservabilityBuilder as Builder, BuilderOptions } from './ObservabilityBuilder'; export { Agent365ExporterOptions } from './tracing/exporter/Agent365ExporterOptions'; +export type { TokenResolver } from './tracing/exporter/Agent365ExporterOptions'; // Tracing constants export { OpenTelemetryConstants } from './tracing/constants'; export { ExporterEventNames } from './tracing/exporter/ExporterEventNames'; diff --git a/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts b/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts index 6e5d005f..2ef12777 100644 --- a/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts +++ b/packages/agents-a365-observability/src/tracing/PerRequestSpanProcessor.ts @@ -19,7 +19,7 @@ type TraceBuffer = { spans: ReadableSpan[]; openCount: number; rootEnded: boolean; - rootCtx?: Context; // holds the request Context (with token in ALS) + rootCtx?: Context; // preserves request-local baggage and custom exporter state startedAtMs: number; rootEndedAtMs?: number; droppedSpans: number; @@ -29,8 +29,8 @@ type FlushReason = 'trace_completed' | 'root_ended_grace' | 'max_trace_age' | 'f /** * Buffers spans per trace and exports once the request completes. - * Token is not stored; we export under the saved request Context so that getExportToken() - * can read the token from the active OpenTelemetry Context at export time. + * Exports under the saved request Context to preserve request-local state. + * Agent365Exporter acquires credentials through its own app-only resolver, not this Context. */ export class PerRequestSpanProcessor implements SpanProcessor { private traces = new Map(); @@ -101,7 +101,7 @@ export class PerRequestSpanProcessor implements SpanProcessor { // Capture a context to export under. // - Use the first seen context as a fallback. - // - If/when the root span starts, prefer its context (contains token via ALS). + // - If/when the root span starts, prefer its request-local context. if (isRootSpan(span)) { buf.rootCtx = ctx; } else { @@ -231,7 +231,7 @@ export class PerRequestSpanProcessor implements SpanProcessor { `[PerRequestSpanProcessor] Flushing trace traceId=${traceId} reason=${reason} spans=${spans.length} rootEnded=${trace.rootEnded}` ); - // Must have captured the root context to access the token + // Restore the original request context for baggage and custom exporters. if (!trace.rootCtx) { logger.error(`[PerRequestSpanProcessor] Missing rootCtx for trace ${traceId}, cannot export spans`); return; @@ -240,7 +240,7 @@ export class PerRequestSpanProcessor implements SpanProcessor { await this.acquireExportSlot(); try { - // Export under the original request Context so exporter can read the token from context.active() + // Credentials are selected by the exporter, independently of this request context. await new Promise((resolve) => { try { context.with(trace.rootCtx as Context, () => { diff --git a/packages/agents-a365-observability/src/tracing/context/token-context.ts b/packages/agents-a365-observability/src/tracing/context/token-context.ts index f8521f6e..cdcf7e7b 100644 --- a/packages/agents-a365-observability/src/tracing/context/token-context.ts +++ b/packages/agents-a365-observability/src/tracing/context/token-context.ts @@ -19,6 +19,8 @@ interface TokenHolder { /** * Run a function within a Context that carries the per-request export token. * This keeps the token only in OTel Context (ALS), never in any registry. + * These helpers remain available for custom export integrations. Agent365Exporter + * does not consume this token; configure its app-only tokenResolver in every mode. * * The token can be updated later via `updateExportToken()` before the trace * is flushed — useful when the callback is long-running and the original diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts index fffc5648..7b58c557 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365Exporter.ts @@ -17,12 +17,10 @@ import { statusName, resolveAgent365Endpoint, getAgent365ObservabilityDomainOverride, - isPerRequestExportEnabled, truncateSpan, estimateSpanBytes, chunkBySize, } from './utils'; -import { getExportToken } from '../context/token-context'; import logger, { formatError } from '../../utils/logging'; import { Agent365ExporterOptions } from './Agent365ExporterOptions'; import { ExporterEventNames } from './ExporterEventNames'; @@ -90,8 +88,7 @@ interface MappedSpan { * Observability span exporter for Agent365: * - Partitions spans by (tenantId, agentId) * - Builds OTLP-like JSON: resourceSpans -> scopeSpans -> spans - * - POSTs per group to https://{endpoint}/observability/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1 - * or, when useS2SEndpoint is true, https://{endpoint}/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1 + * - POSTs per group to https://{endpoint}/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces?api-version=1 * - Adds Bearer token via token_resolver(agentId, tenantId) */ export class Agent365Exporter implements SpanExporter { @@ -110,8 +107,14 @@ export class Agent365Exporter implements SpanExporter { throw new Error('Agent365ExporterOptions must be provided (was null/undefined)'); } - if (!isPerRequestExportEnabled() && !options.tokenResolver) { - throw new Error('Agent365Exporter tokenResolver must be provided for batch export'); + if (typeof options.tokenResolver !== 'function') { + throw new Error( + 'Agent365Exporter requires an app-only OBS tokenResolver. ' + + 'Per-request export now requires withTokenResolver(...) or ' + + 'Agent365ExporterOptions.tokenResolver; it no longer reads tokens from ' + + 'runWithExportToken. Resolvers should cache the acquired token; see ' + + 'AgenticTokenCache in @microsoft/agents-a365-observability-hosting.', + ); } this.options = options; this.configProvider = configProvider; @@ -189,9 +192,8 @@ export class Agent365Exporter implements SpanExporter { logger.info(`[Agent365Exporter] Split ${spans.length} spans into ${chunks.length} chunks for tenantId: ${tenantId}, agentId: ${agentId}`); } - // Select endpoint path based on S2S flag (includes tenantId in path) - const servicePrefix = this.options.useS2SEndpoint ? '/observabilityService' : '/observability'; - const endpointRelativePath = `${servicePrefix}/tenants/${encodeURIComponent(tenantId)}/otlp/agents/${encodeURIComponent(agentId)}/traces`; + // OBS routing is independent of the agent's workload authentication flow. + const endpointRelativePath = `/observabilityService/tenants/${encodeURIComponent(tenantId)}/otlp/agents/${encodeURIComponent(agentId)}/traces`; let url: string; const domainOverride = getAgent365ObservabilityDomainOverride(this.configProvider); @@ -207,36 +209,20 @@ export class Agent365Exporter implements SpanExporter { 'content-type': 'application/json' }; - let token: string | null = null; - let tokenNotResolvedReason: string | null = null; - if (isPerRequestExportEnabled()) { - // For per-request export, get token from OTel Context - token = getExportToken() ?? null; - if (!token) { - tokenNotResolvedReason = 'No token available in OTel Context for per-request export'; - } - } else { - // For batch export, use tokenResolver - if (!this.options.tokenResolver) { - tokenNotResolvedReason = 'tokenResolver is undefined'; - } else { - const tokenResult = this.options.tokenResolver(agentId, tenantId); - token = tokenResult instanceof Promise ? await tokenResult : tokenResult; - if (token) { - logger.info('[Agent365Exporter] Token resolved successfully via tokenResolver'); - } else { - tokenNotResolvedReason = 'No token resolved via tokenResolver'; - } - } + if (typeof this.options.tokenResolver !== 'function') { + // Defensive: constructor already rejects a missing resolver; this catches mutation of options after construction. + throw new Error('Agent365Exporter tokenResolver was cleared after construction'); } + const token = await this.options.tokenResolver(agentId, tenantId); - if (token) { + if (token?.trim()) { + logger.info('[Agent365Exporter] Token resolved successfully via app-only tokenResolver'); headers['authorization'] = `Bearer ${token}`; } else { - const skipReason = tokenNotResolvedReason || 'Token not resolved for export request'; + const skipReason = 'No token resolved via app-only tokenResolver'; logger.event(ExporterEventNames.EXPORT_GROUP, false, 0, `skip exporting: ${skipReason}`, { tenantId, agentId }); - return; + throw new Error(skipReason); } // Always include tenant id header diff --git a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts index 340f462a..9b1e7c97 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/Agent365ExporterOptions.ts @@ -6,9 +6,11 @@ import { ClusterCategory } from '@microsoft/agents-a365-runtime'; /** - * A function that resolves and returns an authentication token for the given agent and tenant. + * A function that resolves an app-only OBS token for the given agent and tenant. + * Delegated (scp) tokens are not accepted by the S2S service. * Implementations may perform synchronous lookup (e.g., in-memory cache) or asynchronous network calls. - * Return null if a token cannot be provided; exporter will log and proceed without an authorization header. + * Used in both batch and per-request modes, independently of workload token context. + * Return null if a token cannot be provided; export fails without sending an HTTP request. */ export type TokenResolver = (agentId: string, tenantId: string) => string | null | Promise; @@ -19,9 +21,9 @@ export type TokenResolver = (agentId: string, tenantId: string) => string | null * defaults so callers can usually construct without arguments and override selectively. * * @property {ClusterCategory | string} clusterCategory Environment / cluster category (e.g. ClusterCategory.preprod, ClusterCategory.prod, default to ClusterCategory.prod). - * @property {TokenResolver} [tokenResolver] Optional delegate to obtain an auth token. If omitted the exporter will - * fall back to reading the cached token (AgenticTokenCacheInstance.getObservabilityToken). - * @property {boolean} [useS2SEndpoint] When true, exporter will POST to the S2S path (/observabilityService/tenants/{tenantId}/otlp/agents/{agentId}/traces). + * @property {TokenResolver} [tokenResolver] App-only OBS token resolver required when constructing Agent365Exporter. + * There is no implicit cache lookup or request-context token fallback. + * @property {boolean} [useS2SEndpoint] Deprecated compatibility option. Export always uses the S2S path, even when false. * @property {number} maxQueueSize Maximum span queue size before drops occur (passed to BatchSpanProcessor). * @property {number} scheduledDelayMilliseconds Delay between automatic batch flush attempts. * @property {number} exporterTimeoutMilliseconds Maximum time (ms) the BatchSpanProcessor waits for the entire export() call to complete before giving up. Covers partitioning, token resolution, and all HTTP retries. @@ -32,11 +34,11 @@ export class Agent365ExporterOptions { /** Environment / cluster category (e.g. ClusterCategory.preprod, ClusterCategory.prod). */ public clusterCategory: ClusterCategory | string = ClusterCategory.prod; - /** Optional delegate to resolve auth token used by exporter */ - public tokenResolver?: TokenResolver; // Optional if ENABLE_A365_OBSERVABILITY_EXPORTER is false + /** Required by Agent365Exporter in every mode; a console-only builder may omit it. */ + public tokenResolver?: TokenResolver; - /** When true, use S2S endpoint path for export. */ - public useS2SEndpoint: boolean = false; + /** @deprecated Export always uses /observabilityService. This option is ignored. */ + public useS2SEndpoint: boolean = true; /** Maximum span queue size before new spans are dropped. */ public maxQueueSize: number = 2048; diff --git a/packages/agents-a365-observability/src/tracing/exporter/utils.ts b/packages/agents-a365-observability/src/tracing/exporter/utils.ts index 857a8ab8..e13eaca5 100644 --- a/packages/agents-a365-observability/src/tracing/exporter/utils.ts +++ b/packages/agents-a365-observability/src/tracing/exporter/utils.ts @@ -166,7 +166,7 @@ export function isAgent365ExporterEnabled( * Check if per-request export is enabled. * Precedence: internal overrides > configuration provider > environment variable. * When enabled, the PerRequestSpanProcessor is used instead of BatchSpanProcessor. - * The token is passed via OTel Context (async local storage) at export time. + * Credential selection is unchanged: Agent365Exporter uses its app-only token resolver. * @param configProvider Optional configuration provider. Defaults to defaultPerRequestSpanProcessorConfigurationProvider if not specified. */ export function isPerRequestExportEnabled( diff --git a/packages/agents-a365-tooling/package.json b/packages/agents-a365-tooling/package.json index 092d4bc3..7d4364c8 100644 --- a/packages/agents-a365-tooling/package.json +++ b/packages/agents-a365-tooling/package.json @@ -36,6 +36,7 @@ "@microsoft/agents-a365-runtime": "workspace:*", "@microsoft/agents-hosting": "catalog:", "@modelcontextprotocol/sdk": "catalog:", + "axios": "catalog:", "express": "catalog:", "hono": "catalog:" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9356688a..a7b264e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -495,6 +495,9 @@ importers: '@modelcontextprotocol/sdk': specifier: 'catalog:' version: 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) + axios: + specifier: ^1.16.0 + version: 1.20.0 express: specifier: 'catalog:' version: 5.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 13eacac6..3ff90f26 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,9 @@ catalog: # Azure packages "@azure/identity": "^4.12.1" + # HTTP client + "axios": "^1.16.0" + # Express framework "express": "^5.2.0" diff --git a/tests/jest.config.cjs b/tests/jest.config.cjs index 3627b5ff..8511a6d0 100644 --- a/tests/jest.config.cjs +++ b/tests/jest.config.cjs @@ -69,6 +69,7 @@ module.exports = { moduleNameMapper: { '^@microsoft/agents-a365-runtime$': '/packages/agents-a365-runtime/src', '^@microsoft/agents-a365-observability$': '/packages/agents-a365-observability/src', + '^@microsoft/agents-a365-observability-hosting$': '/packages/agents-a365-observability-hosting/src', '^@microsoft/agents-a365-observability-extensions-langchain$': '/packages/agents-a365-observability-extensions-langchain/src', '^@microsoft/agents-a365-observability-extensions-openai$': '/packages/agents-a365-observability-extensions-openai/src', '^@microsoft/agents-a365-observability-tokencache$': '/packages/agents-a365-observability-tokencache/src', diff --git a/tests/observability/core/agent365-exporter.test.ts b/tests/observability/core/agent365-exporter.test.ts index 724eaccc..8c3027ee 100644 --- a/tests/observability/core/agent365-exporter.test.ts +++ b/tests/observability/core/agent365-exporter.test.ts @@ -14,6 +14,7 @@ import { truncateSpan } from '@microsoft/agents-a365-observability/src/tracing/e import { runWithExportToken } from '@microsoft/agents-a365-observability/src/tracing/context/token-context'; import { context as otelContext } from '@opentelemetry/api'; import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; +import { AgenticTokenCache } from '@microsoft/agents-a365-observability-hosting'; // Minimal mock span factory function makeSpan(attrs: Record, name = 'test'): ReadableSpan { @@ -42,6 +43,10 @@ function makeSpan(attrs: Record, name = 'test'): ReadableSpan { const tenantId = 'tenant-11111111-1111-1111-1111-111111111111'; const agentId = 'agent-22222222-2222-2222-2222-222222222222'; +function makeToken(claims: Record): string { + return `${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.offline-signature`; +} + // Patch global fetch const originalFetch = global.fetch; @@ -138,7 +143,7 @@ describe('Agent365Exporter', () => { expect(fetchCalls.length).toBe(1); const urlArg = fetchCalls[0][0]; const headersArg = fetchCalls[0][1].headers; - expect(urlArg).toBe(`${expectedUrl}/observability/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); + expect(urlArg).toBe(`${expectedUrl}/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); expect(headersArg['x-ms-tenant-id']).toBe(tenantId); expect(headersArg['authorization']).toBe(`Bearer ${token}`); }); @@ -193,7 +198,7 @@ describe('Agent365Exporter', () => { const urlArg = fetchCalls[0][0] as string; const headersArg = fetchCalls[0][1].headers as Record; - expect(urlArg).toBe(`${expectedBaseUrl}/observability/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); + expect(urlArg).toBe(`${expectedBaseUrl}/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); expect(headersArg['x-ms-tenant-id']).toBe(tenantId); expect(headersArg['authorization']).toBe(`Bearer ${token}`); }); @@ -218,7 +223,7 @@ describe('Agent365Exporter', () => { expect(fetchCalls.length).toBe(1); const urlArg = fetchCalls[0][0]; const headersArg = fetchCalls[0][1].headers; - expect(urlArg).toBe(`https://agent365.svc.cloud.microsoft/observability/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); + expect(urlArg).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); expect(headersArg['x-ms-tenant-id']).toBe(tenantId); expect(headersArg['authorization']).toBe(`Bearer ${token}`); }); @@ -227,15 +232,96 @@ describe('Agent365Exporter', () => { const opts = new Agent365ExporterOptions(); opts.clusterCategory = 'local'; // Intentionally omit tokenResolver - expect(() => new Agent365Exporter(opts)).toThrow(/tokenResolver must be provided/); + expect(() => new Agent365Exporter(opts)) + .toThrow(/requires an app-only OBS tokenResolver[\s\S]*withTokenResolver/); + }); + + it.each([null, '', ' '])('reports failed export without sending an empty token (%j)', async (token) => { + mockFetchSequence([200]); + const opts = new Agent365ExporterOptions(); + opts.tokenResolver = () => token; + const exporter = new Agent365Exporter(opts); + const callback = jest.fn(); + + await exporter.export([makeSpan({ + [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, + })], callback); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED }); + expect(getFetchCalls()).toHaveLength(0); + }); + + it('reports resolver acquisition failure without sending a request or falling back', async () => { + mockFetchSequence([200]); + const opts = new Agent365ExporterOptions(); + const resolver = jest.fn(async (_agentId: string, _tenantId: string) => { + throw new Error('app-only token acquisition failed'); + }); + opts.tokenResolver = resolver; + const exporter = new Agent365Exporter(opts); + const callback = jest.fn(); + + await exporter.export([makeSpan({ + [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, + })], callback); + + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenCalledWith(agentId, tenantId); + expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED }); + expect(getFetchCalls()).toHaveLength(0); }); - it('uses S2S endpoint path when useS2SEndpoint is true', async () => { + + it.each([ + { description: 'absent', roles: undefined }, + { description: 'empty', roles: [] }, + ])('exports an app token with $description roles from the hosting resolver', async ({ roles }) => { + mockFetchSequence([200]); + const claims = { + idtyp: 'app', tid: tenantId, azp: agentId, roles, + aud: '9b975845-388f-4429-889e-eab1ef63949c', + exp: Math.floor(Date.now() / 1000) + 300, + }; + const token = makeToken(claims); + const cache = new AgenticTokenCache(); + const resolver = jest.fn(async (_agentId: string, _tenantId: string, _scopes: readonly string[]) => token); + await cache.RefreshObservabilityToken(agentId, tenantId, resolver); + const opts = new Agent365ExporterOptions(); + opts.useS2SEndpoint = false; + opts.tokenResolver = (agent, tenant) => cache.getObservabilityToken(agent, tenant); + const exporter = new Agent365Exporter(opts); + const callback = jest.fn(); + + await exporter.export([makeSpan({ + [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, + })], callback); + + expect(resolver).toHaveBeenCalledWith(agentId, tenantId, [ + 'api://9b975845-388f-4429-889e-eab1ef63949c/.default', + ]); + expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); + const calls = getFetchCalls(); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); + expect(calls[0][1].headers['authorization']).toBe(`Bearer ${token}`); + }); + + it('defaults the legacy endpoint option to S2S', () => { + expect(new Agent365ExporterOptions().useS2SEndpoint).toBe(true); + }); + + it.each([undefined, false, true])('always uses S2S when useS2SEndpoint is %s', async (useS2SEndpoint) => { mockFetchSequence([200]); const token = 'tok-s2s'; const opts = new Agent365ExporterOptions(); opts.clusterCategory = 'prod'; opts.tokenResolver = () => token; - opts.useS2SEndpoint = true; + if (useS2SEndpoint !== undefined) { + opts.useS2SEndpoint = useS2SEndpoint; + } const exporter = new Agent365Exporter(opts); const spans = [ @@ -259,14 +345,14 @@ describe('Agent365Exporter', () => { expect(headersArg['x-ms-tenant-id']).toBe(tenantId); }); - it('uses S2S endpoint path with domain override and sets x-ms-tenant-id', async () => { + it.each([false, true])('uses S2S with a domain override and legacy option %s', async (useS2SEndpoint) => { mockFetchSequence([200]); process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE = 'https://custom.domain'; const token = 'tok-s2s-custom'; const opts = new Agent365ExporterOptions(); opts.clusterCategory = 'prod'; opts.tokenResolver = () => token; - opts.useS2SEndpoint = true; + opts.useS2SEndpoint = useS2SEndpoint; const exporter = new Agent365Exporter(opts); const spans = [ @@ -289,6 +375,25 @@ describe('Agent365Exporter', () => { }); + it.each([401, 403, 404])('does not fall back to OBO when S2S returns %s', async (status) => { + mockFetchSequence([status]); + const opts = new Agent365ExporterOptions(); + opts.tokenResolver = () => 'test-obs-token'; + opts.useS2SEndpoint = false; + const exporter = new Agent365Exporter(opts); + const callback = jest.fn(); + + await exporter.export([makeSpan({ + [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, + })], callback); + + expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED }); + const calls = getFetchCalls(); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); + }); + it('passes httpRequestTimeoutMilliseconds to fetch AbortSignal.timeout', async () => { const customTimeout = 12345; mockFetchSequence([200]); @@ -1012,7 +1117,7 @@ describe('Agent365Exporter', () => { }); }); - describe('per-request export (token from OTel Context)', () => { + describe('per-request app-only export', () => { let contextManager: AsyncLocalStorageContextManager | undefined; beforeEach(() => { @@ -1028,12 +1133,23 @@ describe('Agent365Exporter', () => { contextManager = undefined; }); - it('acquires export token from OTel Context when per-request export is enabled', async () => { + it.each([ + { useS2SEndpoint: false, roles: undefined }, + { useS2SEndpoint: true, roles: [] }, + ])('uses its app-only resolver with legacy option $useS2SEndpoint and ignores delegated context', async ({ useS2SEndpoint, roles }) => { mockFetchSequence([200]); process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; const opts = new Agent365ExporterOptions(); opts.clusterCategory = 'local'; + opts.useS2SEndpoint = useS2SEndpoint; + const exportToken = makeToken({ + idtyp: 'app', tid: tenantId, azp: agentId, roles, + aud: '9b975845-388f-4429-889e-eab1ef63949c', + exp: Math.floor(Date.now() / 1000) + 300, + }); + const resolver = jest.fn(async (_agentId: string, _tenantId: string) => exportToken); + opts.tokenResolver = resolver; const exporter = new Agent365Exporter(opts); const spans = [ @@ -1044,18 +1160,94 @@ describe('Agent365Exporter', () => { ]; const callback = jest.fn(); - const exportToken = 'tok-from-context'; - await runWithExportToken(exportToken, async () => exporter.export(spans, callback)); + const delegatedToken = makeToken({ scp: 'User.Read', idtyp: 'user', tid: tenantId }); + await runWithExportToken(delegatedToken, async () => exporter.export(spans, callback)); expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenCalledWith(agentId, tenantId); - // Verify export was attempted (should be greater than 0 when enabled) const fetchCalls = getFetchCalls(); - expect(fetchCalls.length).toBeGreaterThan(0); + expect(fetchCalls).toHaveLength(1); + expect(fetchCalls[0][0]).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); - // Verify token came from OTel Context (per-request mode) + // The workload's delegated context token must not become an OBS credential. const headersArg = fetchCalls[0][1].headers as Record; expect(headersArg['authorization']).toBe(`Bearer ${exportToken}`); }); + + it.each([401, 403, 404])('does not fall back from per-request S2S after HTTP %s', async (status) => { + mockFetchSequence([status]); + process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; + const opts = new Agent365ExporterOptions(); + opts.useS2SEndpoint = false; + const resolver = jest.fn(async () => makeToken({ idtyp: 'app', tid: tenantId, azp: agentId })); + opts.tokenResolver = resolver; + const exporter = new Agent365Exporter(opts); + const callback = jest.fn(); + + await runWithExportToken(makeToken({ scp: 'User.Read', idtyp: 'user' }), async () => exporter.export([makeSpan({ + [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, + })], callback)); + + expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED }); + expect(resolver).toHaveBeenCalledTimes(1); + const calls = getFetchCalls(); + expect(calls).toHaveLength(1); + expect(calls[0][0]).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); + }); + + it('exports with the app-only resolver when no context token exists', async () => { + mockFetchSequence([200]); + process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; + const opts = new Agent365ExporterOptions(); + const resolver = jest.fn(() => makeToken({ idtyp: 'app', tid: tenantId, azp: agentId })); + opts.tokenResolver = resolver; + const exporter = new Agent365Exporter(opts); + const callback = jest.fn(); + + await exporter.export([makeSpan({ + [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, + })], callback); + + expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.SUCCESS }); + expect(resolver).toHaveBeenCalledTimes(1); + expect(getFetchCalls()).toHaveLength(1); + }); + + it('requires an app-only resolver even when a context token is present', () => { + mockFetchSequence([200]); + process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; + + runWithExportToken(makeToken({ scp: 'User.Read' }), () => { + expect(() => new Agent365Exporter(new Agent365ExporterOptions())) + .toThrow(/requires an app-only OBS tokenResolver[\s\S]*withTokenResolver/); + }); + expect(getFetchCalls()).toHaveLength(0); + }); + + it.each(['missing', 'failed'])('does not use a delegated context token after %s app-only acquisition', async (failure) => { + mockFetchSequence([200]); + process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; + const opts = new Agent365ExporterOptions(); + const resolver = jest.fn(async () => { + if (failure === 'failed') throw new Error('App-only acquisition failed'); + return null; + }); + opts.tokenResolver = resolver; + const exporter = new Agent365Exporter(opts); + const callback = jest.fn(); + + await runWithExportToken(makeToken({ scp: 'User.Read' }), async () => exporter.export([makeSpan({ + [OpenTelemetryConstants.TENANT_ID_KEY]: tenantId, + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agentId, + })], callback)); + + expect(callback).toHaveBeenCalledWith({ code: ExportResultCode.FAILED }); + expect(resolver).toHaveBeenCalledTimes(1); + expect(getFetchCalls()).toHaveLength(0); + }); }); }); diff --git a/tests/observability/core/observabilityBuilder-per-request-auth.test.ts b/tests/observability/core/observabilityBuilder-per-request-auth.test.ts new file mode 100644 index 00000000..04022b8d --- /dev/null +++ b/tests/observability/core/observabilityBuilder-per-request-auth.test.ts @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; +import { context, trace } from '@opentelemetry/api'; +import { ObservabilityBuilder } from '@microsoft/agents-a365-observability/src/ObservabilityBuilder'; +import { OpenTelemetryConstants } from '@microsoft/agents-a365-observability/src/tracing/constants'; +import { runWithExportToken } from '@microsoft/agents-a365-observability/src/tracing/context/token-context'; +import type { TokenResolver } from '@microsoft/agents-a365-observability'; + +const tenantId = '11111111-1111-1111-1111-111111111111'; +const agentId = '22222222-2222-2222-2222-222222222222'; +const originalFetch = global.fetch; + +function token(claims: Record): string { + return `${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.offline-signature`; +} + +function appToken(agent = agentId, tenant = tenantId): string { + return token({ + idtyp: 'app', azp: agent, tid: tenant, + aud: '9b975845-388f-4429-889e-eab1ef63949c', + exp: Math.floor(Date.now() / 1000) + 300, + }); +} + +describe('ObservabilityBuilder per-request OBS authentication', () => { + let originalEnv: NodeJS.ProcessEnv; + let builder: ObservabilityBuilder; + let requests: Request[]; + + beforeEach(() => { + originalEnv = { ...process.env }; + process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'true'; + process.env.ENABLE_A365_OBSERVABILITY_PER_REQUEST_EXPORT = 'true'; + delete process.env.A365_OBSERVABILITY_DOMAIN_OVERRIDE; + requests = []; + global.fetch = jest.fn(async (input, init) => { + requests.push(new Request(input, init)); + return new Response('{}', { status: 200 }); + }); + builder = new ObservabilityBuilder().withService('per-request-auth-regression'); + }); + + afterEach(async () => { + await builder.shutdown(); + trace.disable(); + context.disable(); + process.env = originalEnv; + global.fetch = originalFetch; + }); + + async function invoke(agent = agentId, tenant = tenantId, contextToken?: string): Promise { + const emit = () => { + trace.getTracer('per-request-auth-regression').startSpan('invoke_agent offline', { + attributes: { + [OpenTelemetryConstants.GEN_AI_OPERATION_NAME_KEY]: 'invoke_agent', + [OpenTelemetryConstants.GEN_AI_AGENT_ID_KEY]: agent, + [OpenTelemetryConstants.TENANT_ID_KEY]: tenant, + }, + }).end(); + }; + if (contextToken === undefined) emit(); + else runWithExportToken(contextToken, emit); + // Span completion schedules asynchronous token resolution and export. + await new Promise(resolve => setImmediate(resolve)); + } + + it.each(['builder', 'exporterOptions', 'builderOverride'])('uses the %s app resolver through the real processor and exporter', async (source) => { + const expectedToken = appToken(); + const resolver = jest.fn(async () => expectedToken); + const overridden = jest.fn(async () => 'must-not-be-used'); + builder.withExporterOptions({ useS2SEndpoint: false }); + if (source !== 'builder') { + builder.withExporterOptions({ + useS2SEndpoint: false, + tokenResolver: source === 'exporterOptions' ? resolver : overridden, + }); + } + if (source !== 'exporterOptions') builder.withTokenResolver(resolver); + builder.start(); + + await invoke(agentId, tenantId, token({ idtyp: 'user', scp: 'User.Read', tid: tenantId })); + + expect(resolver).toHaveBeenCalledWith(agentId, tenantId); + expect(overridden).not.toHaveBeenCalled(); + expect(requests).toHaveLength(1); + expect(requests[0].url).toBe(`https://agent365.svc.cloud.microsoft/observabilityService/tenants/${tenantId}/otlp/agents/${agentId}/traces?api-version=1`); + expect(requests[0].headers.get('authorization')).toBe(`Bearer ${expectedToken}`); + }); + + it('does not require a request-context credential', async () => { + const resolver = jest.fn(async () => appToken()); + builder.withTokenResolver(resolver).start(); + + await invoke(); + + expect(resolver).toHaveBeenCalledWith(agentId, tenantId); + expect(requests).toHaveLength(1); + }); + + it('resolves separate app identities for concurrent workload contexts', async () => { + const otherAgent = '33333333-3333-3333-3333-333333333333'; + const otherTenant = '44444444-4444-4444-4444-444444444444'; + const tokens = new Map([ + [`${agentId}:${tenantId}`, appToken()], + [`${otherAgent}:${otherTenant}`, appToken(otherAgent, otherTenant)], + ]); + const resolver = jest.fn(async (agent, tenant) => tokens.get(`${agent}:${tenant}`) ?? null); + builder.withTokenResolver(resolver).start(); + + await Promise.all([ + invoke(agentId, tenantId, token({ scp: 'User.Read', oid: 'first-user' })), + invoke(otherAgent, otherTenant, token({ scp: 'User.Read', oid: 'second-user' })), + ]); + + expect(resolver).toHaveBeenCalledTimes(2); + expect(resolver).toHaveBeenCalledWith(agentId, tenantId); + expect(resolver).toHaveBeenCalledWith(otherAgent, otherTenant); + expect(requests).toHaveLength(2); + for (const [agent, tenant] of [[agentId, tenantId], [otherAgent, otherTenant]]) { + const request = requests.find(candidate => candidate.headers.get('x-ms-tenant-id') === tenant); + expect(request?.headers.get('authorization')).toBe(`Bearer ${tokens.get(`${agent}:${tenant}`)}`); + } + }); + + it('fails configuration without an app resolver when OBS export is enabled', () => { + expect(() => builder.start()) + .toThrow(/requires an app-only OBS tokenResolver[\s\S]*withTokenResolver/); + expect(requests).toHaveLength(0); + }); + + it('keeps console-only configuration usable without an app resolver', () => { + process.env.ENABLE_A365_OBSERVABILITY_EXPORTER = 'false'; + expect(() => builder.start()).not.toThrow(); + expect(requests).toHaveLength(0); + }); +}); diff --git a/tests/observability/extension/hosting/agentic-token-cache.test.ts b/tests/observability/extension/hosting/agentic-token-cache.test.ts index bacdcb6e..9b5d6d37 100644 --- a/tests/observability/extension/hosting/agentic-token-cache.test.ts +++ b/tests/observability/extension/hosting/agentic-token-cache.test.ts @@ -1,265 +1,223 @@ -// ------------------------------------------------------------------------------ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// ------------------------------------------------------------------------------ -import { AgenticTokenCacheInstance } from '@microsoft/agents-a365-observability-hosting'; +import { AgenticTokenCache, ObservabilityTokenResolver } from '@microsoft/agents-a365-observability-hosting'; +import { ObservabilityConfiguration } from '@microsoft/agents-a365-observability'; +import type { Authorization, TurnContext } from '@microsoft/agents-hosting'; -interface TurnContextStub { activity: { id: string } } -interface AuthorizationStub { - exchangeToken: (...args: any[]) => Promise<{ token: string | undefined }> - getToken: (...args: any[]) => Promise<{ token: string }> - signOut: () => Promise | void - onSignInSuccess: () => void - onSignInFailure: () => void -} -interface SequenceStep { token?: string; error?: unknown } - -const makeTurnContext = (): TurnContextStub => ({ activity: { id: 'a1' } }); - -// Helper to cast our minimal stub to the SDK TurnContext type expected by the cache -const asTurnContext = (stub: TurnContextStub): import('@microsoft/agents-hosting').TurnContext => { - return stub as unknown as import('@microsoft/agents-hosting').TurnContext; -}; +const obsScopes = ['api://9b975845-388f-4429-889e-eab1ef63949c/.default']; -function makeJwtWithExp(expSecondsFromNow: number): string { +function makeJwtWithExp(expSecondsFromNow: number, claims: Record = {}): string { const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); - const exp = Math.floor(Date.now() / 1000) + expSecondsFromNow; - const payload = Buffer.from(JSON.stringify({ exp })).toString('base64url'); - return `${header}.${payload}.sig`; + const payload = Buffer.from(JSON.stringify({ + exp: Math.floor(Date.now() / 1000) + expSecondsFromNow, + idtyp: 'app', + ...claims, + })).toString('base64url'); + return `${header}.${payload}.test-signature`; } -function makeAuthorizationMock(sequence: SequenceStep[]): AuthorizationStub { - let call = 0; - const authLike: AuthorizationStub = { - exchangeToken: async () => { - const current = sequence[Math.min(call, sequence.length - 1)]; - call++; - if (current.error) throw current.error; - return { token: current.token || '' }; - }, - getToken: async () => ({ token: 'unused' }), - signOut: async () => {}, - onSignInSuccess: () => {}, - onSignInFailure: () => {} - }; - return authLike; -} +describe('AgenticTokenCache app-only OBS authentication', () => { + let cache: AgenticTokenCache; -describe('AgenticTokenCacheInstance', () => { beforeEach(() => { - AgenticTokenCacheInstance.invalidateAll(); + cache = new AgenticTokenCache(); jest.useFakeTimers(); }); + afterEach(() => { jest.useRealTimers(); }); it('returns null when no entry exists', () => { - const token = AgenticTokenCacheInstance.getObservabilityToken('agentX', 'tenantY'); - expect(token).toBeNull(); + expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); }); - it('exchanges and caches token on first call', async () => { + it('passes exporting identity and configured OBS scopes to an app-only resolver', async () => { const token = makeJwtWithExp(300); - const auth = makeAuthorizationMock([{ token }]); - await AgenticTokenCacheInstance.RefreshObservabilityToken( - 'agentA', - 'tenantA', - asTurnContext(makeTurnContext()), - auth as any, - ['scope.read'] - ); - const tokenReturned = AgenticTokenCacheInstance.getObservabilityToken('agentA', 'tenantA'); - expect(tokenReturned).not.toBeNull(); - expect(tokenReturned).toBe(token); + const resolver = jest.fn, Parameters>(() => token); + + await cache.RefreshObservabilityToken('agent', 'tenant', resolver); + + expect(resolver).toHaveBeenCalledTimes(1); + expect(resolver).toHaveBeenCalledWith('agent', 'tenant', obsScopes); + expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token); }); - it('retries on retriable error then succeeds', async () => { + it('uses a configuration provider for app-only scopes', async () => { + const scopes = ['api://custom-obs/.default']; + cache = new AgenticTokenCache({ + getConfiguration: () => new ObservabilityConfiguration({ + observabilityAuthenticationScopes: () => scopes, + }), + }); + const resolver = jest.fn(() => makeJwtWithExp(300)); + + await cache.RefreshObservabilityToken('agent', 'tenant', resolver); + + expect(resolver).toHaveBeenCalledWith('agent', 'tenant', scopes); + }); + + it.each([ + { description: 'absent', roles: undefined }, + { description: 'empty', roles: [] }, + ])('caches an explicitly app-only token with $description roles', async ({ roles }) => { + const token = makeJwtWithExp(300, { roles }); + await cache.RefreshObservabilityToken('agent', 'tenant', () => token); + expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token); + }); + + it('deduplicates concurrent roleless-token acquisitions for the same identity', async () => { const token = makeJwtWithExp(300); - const retriableErr = { status: 500, message: 'server error' }; - const sequence: SequenceStep[] = [ - { error: retriableErr }, - { token } - ]; - let call = 0; - const exchangeFn = jest.fn(async () => { - const current = sequence[Math.min(call, sequence.length - 1)]; - call++; - if (current.error) throw current.error; - return { token: current.token }; + const resolver = jest.fn(async () => token); + await Promise.all(Array.from({ length: 8 }, () => + cache.RefreshObservabilityToken('agent', 'tenant', resolver))); + expect(resolver).toHaveBeenCalledTimes(1); + expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token); + }); + + it('fails for an empty scope configuration without requesting a token', async () => { + cache = new AgenticTokenCache({ + getConfiguration: () => new ObservabilityConfiguration({ + observabilityAuthenticationScopes: () => [], + }), }); - const auth: AuthorizationStub = { - exchangeToken: exchangeFn, - getToken: async () => ({ token: 'unused' }), - signOut: async () => {}, - onSignInSuccess: () => {}, - onSignInFailure: () => {} + const resolver = jest.fn(() => makeJwtWithExp(300)); + await expect(cache.RefreshObservabilityToken('agent', 'tenant', resolver)).rejects.toThrow('No valid scopes'); + expect(resolver).not.toHaveBeenCalled(); + }); + + it.each(['agentic', 'obo'])('rejects legacy %s user authorization without exchanging a token', async (handler) => { + const exchangeToken = jest.fn(); + const authorization: Authorization = { + exchangeToken, + getToken: jest.fn(), + signOut: jest.fn(), + onSignInSuccess: jest.fn(), + onSignInFailure: jest.fn(), }; - const p = AgenticTokenCacheInstance.RefreshObservabilityToken( - 'agentB', - 'tenantB', - asTurnContext(makeTurnContext()), - auth as any, - ['scope.read'] - ); - await (jest as any).advanceTimersByTimeAsync?.(1000) || jest.advanceTimersByTime(1000); - await p; - const tokenReturned = AgenticTokenCacheInstance.getObservabilityToken('agentB', 'tenantB'); - expect(tokenReturned).not.toBeNull(); - expect(tokenReturned).toBe(token); - expect(exchangeFn).toHaveBeenCalledTimes(2); + const context = {} as TurnContext; + + await expect(cache.RefreshObservabilityToken( + 'agent', 'tenant', context, authorization, obsScopes, handler, + )).rejects.toThrow('S2S OBS requires an app-only token resolver'); + + expect(exchangeToken).not.toHaveBeenCalled(); + expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); }); - it('stops on non-retriable error and leaves token null', async () => { - const nonRetriableErr = { status: 400, message: 'bad request' }; - const auth = makeAuthorizationMock([ - { error: nonRetriableErr }, - { token: makeJwtWithExp(300) } // should not be used - ]); - await AgenticTokenCacheInstance.RefreshObservabilityToken( - 'agentC', - 'tenantC', - asTurnContext(makeTurnContext()), - auth as any, - ['scope.read'] - ); - const token = AgenticTokenCacheInstance.getObservabilityToken('agentC', 'tenantC'); - expect(token).toBeNull(); + it.each([['', 'tenant'], ['agent', ' ']])('rejects empty identity (%s, %s)', async (agent, tenant) => { + const resolver = jest.fn(() => makeJwtWithExp(300)); + await expect(cache.RefreshObservabilityToken(agent, tenant, resolver)).rejects.toThrow('Agent and tenant IDs'); + expect(resolver).not.toHaveBeenCalled(); }); - it('treats near-expiry token as expired (skew refresh)', async () => { - const auth = makeAuthorizationMock([{ token: makeJwtWithExp(30) }]); - await AgenticTokenCacheInstance.RefreshObservabilityToken( - 'agentD', - 'tenantD', - asTurnContext(makeTurnContext()), - auth as any, - ['scope.read'] - ); - const token = AgenticTokenCacheInstance.getObservabilityToken('agentD', 'tenantD'); - expect(token).toBeNull(); + it('retries a transient acquisition failure then caches the app-only token', async () => { + const token = makeJwtWithExp(300); + const resolver = jest.fn() + .mockRejectedValueOnce({ status: 500, message: 'service unavailable' }) + .mockResolvedValueOnce(token); + + const pending = cache.RefreshObservabilityToken('agent', 'tenant', resolver); + await jest.advanceTimersByTimeAsync(1000); + await pending; + + expect(resolver).toHaveBeenCalledTimes(2); + expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token); }); - it('returns cached token before expiry then invalid after advancing time', async () => { - const auth = makeAuthorizationMock([{ token: makeJwtWithExp(120) }]); - await AgenticTokenCacheInstance.RefreshObservabilityToken( - 'agentE', - 'tenantE', - asTurnContext(makeTurnContext()), - auth as any, - ['scope.read'] - ); - const tokenBefore = AgenticTokenCacheInstance.getObservabilityToken('agentE', 'tenantE'); - expect(tokenBefore).not.toBeNull(); + it('surfaces a permanent acquisition failure and clears stale tokens', async () => { + await cache.RefreshObservabilityToken('agent', 'tenant', () => makeJwtWithExp(120)); jest.advanceTimersByTime(61_000); - const tokenAfter = AgenticTokenCacheInstance.getObservabilityToken('agentE', 'tenantE'); - expect(tokenAfter).toBeNull(); + const error = new Error('permission denied'); + const resolver = jest.fn().mockRejectedValue(error); + + await expect(cache.RefreshObservabilityToken('agent', 'tenant', resolver)).rejects.toBe(error); + + expect(resolver).toHaveBeenCalledTimes(1); + expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); }); - it('evicts oldest entry when cache exceeds max size', async () => { - const { AgenticTokenCache } = require('@microsoft/agents-a365-observability-hosting'); - const cache = new AgenticTokenCache(); - const map = (cache as any)._map as Map; + it('surfaces exhausted transient failures without caching a token', async () => { + const error = { status: 503, message: 'service unavailable' }; + const resolver = jest.fn().mockRejectedValue(error); + const pending = expect(cache.RefreshObservabilityToken('agent', 'tenant', resolver)).rejects.toBe(error); + await jest.advanceTimersByTimeAsync(1000); + await pending; + expect(resolver).toHaveBeenCalledTimes(3); + expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); + }); - // Pre-fill the map to capacity - const MAX = (cache as any)._maxCacheSize as number; - for (let i = 0; i < MAX; i++) { - map.set(`agent-${i}:tenant-${i}`, { scopes: ['s'], token: `t-${i}`, acquiredOn: Date.now() }); - } - expect(map.size).toBe(MAX); + it.each([null, '', ' '])('surfaces an empty resolver result (%s)', async (token) => { + await expect(cache.RefreshObservabilityToken('agent', 'tenant', () => token)).rejects.toThrow('returned no token'); + expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); + }); - // Insert one more via RefreshObservabilityToken - const token = makeJwtWithExp(300); - const auth = makeAuthorizationMock([{ token }]); - await cache.RefreshObservabilityToken( - 'agent-new', - 'tenant-new', - asTurnContext(makeTurnContext()), - auth as any, - ['scope.read'] - ); - - // Size should still be at MAX (oldest evicted, new one added) - expect(map.size).toBe(MAX); - // First entry should have been evicted - expect(map.has('agent-0:tenant-0')).toBe(false); - // New entry should exist - expect(map.has('agent-new:tenant-new')).toBe(true); + it.each([-30, 0, 30])('does not return a token expiring in %s seconds', async (seconds) => { + await cache.RefreshObservabilityToken('agent', 'tenant', () => makeJwtWithExp(seconds)); + expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); }); - it('passes authHandlerName to exchangeToken when provided', async () => { - const token = makeJwtWithExp(300); - const exchangeFn = jest.fn(async (..._args: any[]) => ({ token })); - const auth: AuthorizationStub = { - exchangeToken: exchangeFn, - getToken: async () => ({ token: 'unused' }), - signOut: async () => {}, - onSignInSuccess: () => {}, - onSignInFailure: () => {} - }; - await AgenticTokenCacheInstance.RefreshObservabilityToken( - 'agentHandler', - 'tenantHandler', - asTurnContext(makeTurnContext()), - auth as any, - ['scope.read'], - 'custom-handler' - ); - expect(exchangeFn).toHaveBeenCalledTimes(1); - expect(exchangeFn.mock.calls[0][1]).toBe('custom-handler'); + it('reuses a cached token and refreshes after expiry skew', async () => { + const token = makeJwtWithExp(120); + const resolver = jest.fn(() => token); + await cache.RefreshObservabilityToken('agent', 'tenant', resolver); + await cache.RefreshObservabilityToken('agent', 'tenant', resolver); + expect(resolver).toHaveBeenCalledTimes(1); + expect(cache.getObservabilityToken('agent', 'tenant')).toBe(token); + jest.advanceTimersByTime(61_000); + expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); + resolver.mockReturnValue(makeJwtWithExp(300)); + await cache.RefreshObservabilityToken('agent', 'tenant', resolver); + expect(resolver).toHaveBeenCalledTimes(2); }); - it('defaults authHandlerName to "agentic" when not provided', async () => { + it('isolates tokens by agent and tenant', async () => { + const resolver = (agent: string, tenant: string) => `${agent}-${tenant}`; + await cache.RefreshObservabilityToken('one', 'tenant-a', resolver); + await cache.RefreshObservabilityToken('two', 'tenant-a', resolver); + await cache.RefreshObservabilityToken('one', 'tenant-b', resolver); + expect(cache.getObservabilityToken('one', 'tenant-a')).toBe('one-tenant-a'); + expect(cache.getObservabilityToken('two', 'tenant-a')).toBe('two-tenant-a'); + expect(cache.getObservabilityToken('one', 'tenant-b')).toBe('one-tenant-b'); + }); + + it('uses a fresh fallback TTL when an opaque token replaces an expired JWT', async () => { + await cache.RefreshObservabilityToken('agent', 'tenant', () => makeJwtWithExp(120)); + jest.advanceTimersByTime(61_000); + await cache.RefreshObservabilityToken('agent', 'tenant', () => 'opaque-app-only-token'); + expect(cache.getObservabilityToken('agent', 'tenant')).toBe('opaque-app-only-token'); + jest.advanceTimersByTime(3_600_000); + expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); + }); + + it('evicts the oldest token when the cache reaches capacity', async () => { const token = makeJwtWithExp(300); - const exchangeFn = jest.fn(async (..._args: any[]) => ({ token })); - const auth: AuthorizationStub = { - exchangeToken: exchangeFn, - getToken: async () => ({ token: 'unused' }), - signOut: async () => {}, - onSignInSuccess: () => {}, - onSignInFailure: () => {} - }; - await AgenticTokenCacheInstance.RefreshObservabilityToken( - 'agentDefault', - 'tenantDefault', - asTurnContext(makeTurnContext()), - auth as any, - ['scope.read'] - ); - expect(exchangeFn).toHaveBeenCalledTimes(1); - expect(exchangeFn.mock.calls[0][1]).toBe('agentic'); + const capacity = cache['_maxCacheSize']; + const resolver = () => token; + for (let i = 0; i <= capacity; i++) { + await cache.RefreshObservabilityToken(`agent-${i}`, 'tenant', resolver); + } + expect(cache.getObservabilityToken('agent-0', 'tenant')).toBeNull(); + expect(cache.getObservabilityToken('agent-1', 'tenant')).toBe(token); + expect(cache.getObservabilityToken(`agent-${capacity}`, 'tenant')).toBe(token); }); - it('caps JWT exp claim to 24 hours', async () => { - const { AgenticTokenCache } = require('@microsoft/agents-a365-observability-hosting'); - const cache = new AgenticTokenCache(); - - // Create JWT with exp 48 hours from now - const farFutureExp = Math.floor(Date.now() / 1000) + (48 * 60 * 60); - const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url'); - const payload = Buffer.from(JSON.stringify({ exp: farFutureExp })).toString('base64url'); - const farFutureToken = `${header}.${payload}.sig`; - - const auth = makeAuthorizationMock([{ token: farFutureToken }]); - await cache.RefreshObservabilityToken( - 'agent-exp', - 'tenant-exp', - asTurnContext(makeTurnContext()), - auth as any, - ['scope.read'] - ); - - const map = (cache as any)._map as Map; - const entry = map.get('agent-exp:tenant-exp'); - expect(entry).toBeDefined(); - expect(entry.expiresOn).toBeDefined(); - - // The expiresOn should be capped to ~24 hours from now (not 48 hours) - const maxAllowed = Date.now() + (24 * 60 * 60 * 1000) + 5000; // 24h + small tolerance - expect(entry.expiresOn).toBeLessThanOrEqual(maxAllowed); - // And should be well below the 48-hour uncapped value - const uncapped = farFutureExp * 1000; - expect(entry.expiresOn).toBeLessThan(uncapped); + it('caps token lifetime to 24 hours', async () => { + const token = makeJwtWithExp(48 * 60 * 60); + await cache.RefreshObservabilityToken('agent', 'tenant', () => token); + jest.advanceTimersByTime(24 * 60 * 60 * 1000); + expect(cache.getObservabilityToken('agent', 'tenant')).toBeNull(); + }); + + it('invalidates one token independently, then all tokens', async () => { + const token = makeJwtWithExp(300); + await cache.RefreshObservabilityToken('one', 'tenant', () => token); + await cache.RefreshObservabilityToken('two', 'tenant', () => token); + cache.invalidateToken('one', 'tenant'); + expect(cache.getObservabilityToken('one', 'tenant')).toBeNull(); + expect(cache.getObservabilityToken('two', 'tenant')).toBe(token); + cache.invalidateAll(); + expect(cache.getObservabilityToken('two', 'tenant')).toBeNull(); }); }); diff --git a/tests/tooling/package-dependencies.test.ts b/tests/tooling/package-dependencies.test.ts new file mode 100644 index 00000000..3ceb8873 --- /dev/null +++ b/tests/tooling/package-dependencies.test.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +describe('tooling runtime dependencies', () => { + it('declares axios directly rather than relying on workspace hoisting', () => { + const manifest = JSON.parse(readFileSync( + join(__dirname, '../../packages/agents-a365-tooling/package.json'), 'utf8', + )); + + expect(manifest.dependencies).toHaveProperty('axios', 'catalog:'); + }); +});