From 67c3d9f0903aa3c79177952222730b6e8c155d49 Mon Sep 17 00:00:00 2001 From: brandonpollett Date: Thu, 11 Jun 2026 20:01:03 -0400 Subject: [PATCH 1/4] Add authentication troubleshooting guide; remove license-key gate; add Adaptive Card Validator references Adds a partner-facing authentication troubleshooting guide and removes the license-key gate from the sample extension (the platform does not support license-key auth, so its inclusion was confusing partners). Authentication troubleshooting guide - New doc/Troubleshooting-Authentication.md: symptom -> cause matrix, deep dives (InvalidAudience/InvalidIssuer/InvalidApplicationId, no-traffic / WAF / network ACLs, AADSTS500011, customer identity model, Application ID URI / identifierUris mistakes), FAQ, pre-flight checklist, and diagnostic bundle to attach to support tickets. - Linked from doc/Authentication.md, doc/AuthenticationDesign.md, and physician/QUICKSTART.md so partners hitting auth errors land on it from any of the obvious entry points. License-key cleanup (sample extension) - Deleted LicenseKeyMiddleware.cs and LicenseKeyOptions.cs (and the now-empty Middleware/ folder). - Removed the LicenseKey middleware from UseFullSecurity, the LicenseKey Swagger security definition, and the LicenseKeyOptions DI registration. - Removed the LicenseKey sections from appsettings.json and appsettings.Development.json. - Updated KnownRoutes/ProcessController/Program.cs comments and XML docs. - Rewrote doc/Authentication.md as JWT-only (single-gate); updated doc/AuthenticationDesign.md overview and .github/copilot-instructions.md. Adaptive Card Validator references - Added a tip linking to https://cardvalidator.copilot.dragon.com/ from physician/QUICKSTART.md, the Python sample README, and the AdaptiveCardPayload.cs XML doc (for IntelliSense discovery). Build verified: dotnet build of SampleExtension.Web -> 0 warnings, 0 errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 9 +- doc/Authentication.md | 143 ++++------ doc/AuthenticationDesign.md | 4 +- doc/Troubleshooting-Authentication.md | 249 ++++++++++++++++++ physician/QUICKSTART.md | 4 + .../AdaptiveCardPayload.cs | 4 + .../Configuration/KnownRoutes.cs | 2 +- .../Configuration/LicenseKeyOptions.cs | 32 --- .../Controllers/ProcessController.cs | 2 +- .../Extensions/ServiceCollectionExtensions.cs | 1 - .../Extensions/WebApplicationExtensions.cs | 32 +-- .../Middleware/LicenseKeyMiddleware.cs | 84 ------ .../Workflow/SampleExtension.Web/Program.cs | 2 +- .../appsettings.Development.json | 3 - .../SampleExtension.Web/appsettings.json | 5 - .../Workflow/pythonSampleExtension/README.md | 3 + 16 files changed, 319 insertions(+), 260 deletions(-) create mode 100644 doc/Troubleshooting-Authentication.md delete mode 100644 physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Configuration/LicenseKeyOptions.cs delete mode 100644 physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Middleware/LicenseKeyMiddleware.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7b030c0c..ee959141 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -35,7 +35,7 @@ This repository contains sample code and tools for developing **Dragon Copilot E - Creates extension manifests and publisher configurations 3. **Documentation** (`doc/`) - - Authentication patterns (JWT + custom license keys) + - Authentication patterns (Microsoft Entra ID JWT) - API contracts and integration guides ## Development Patterns @@ -46,14 +46,13 @@ This repository contains sample code and tools for developing **Dragon Copilot E public async Task ProcessAsync([FromBody] ProcessRequest request) ``` -### Authentication Layers +### Authentication 1. **JWT Authentication**: Microsoft Entra ID integration for service-to-service auth -2. **License Key Validation**: Custom business logic for subscription/feature control -3. **Conditional Security**: Can be disabled for development environments +2. **Conditional Security**: Can be disabled for development environments ### Configuration Patterns - **Development**: Authentication disabled for easier testing -- **Production**: Full security with Entra ID + license validation +- **Production**: JWT authentication with Microsoft Entra ID - **Environment-specific**: `appsettings.json` vs `appsettings.Development.json` ## Extension Manifest Format diff --git a/doc/Authentication.md b/doc/Authentication.md index 8faa0f07..ce724cf3 100644 --- a/doc/Authentication.md +++ b/doc/Authentication.md @@ -2,114 +2,81 @@ ## Overview -The Dragon Copilot Sample Extension implements a multi-layered security approach combining **Microsoft Entra ID (Azure AD) JWT authentication** with **custom license key validation**. This design provides both enterprise-grade identity verification and flexible business logic enforcement. +The Dragon Copilot Sample Extension authenticates incoming service-to-service requests using **Microsoft Entra ID (Azure AD) JWT bearer tokens**. The Dragon Copilot Extension Runtime acquires a token for the extension's app registration and includes it as a standard `Authorization: Bearer ` header on every request. -## Architecture - -The security system uses a **dual-gate approach**: - -1. **First Gate**: JWT Authentication & Authorization (Microsoft Entra ID) - The protection of the service-to-service requests from the Dragon Copilot Extension Runtime is covered in detail in [AuthenticationDesign.md](AuthenticationDesign.md). +> **Hitting auth errors?** See [Troubleshooting-Authentication.md](Troubleshooting-Authentication.md) for a symptom → cause matrix, FAQ, and pre-flight checklist covering the most common partner issues (`InvalidAudience`/`InvalidIssuer`, `identifierUris` mistakes, `AADSTS500011`, and more). -2. **Second Gate**: License Key Validation (Custom Business Logic) +## Architecture ``` -┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ ┌─────────────┐ -│ Request │--->│ JWT Auth & │--->│ License Key │--->│ Protected │ -│ │ │ Authorization│ │ Validation │ │ Endpoint │ -└─────────────┘ └──────────────┘ └─────────────────┘ └─────────────┘ - ↓ ↓ - 401 Unauthorized 403 Forbidden +┌─────────────┐ ┌──────────────────────┐ ┌─────────────┐ +│ Request │--->│ JWT Authentication & │--->│ Protected │ +│ │ │ Authorization │ │ Endpoint │ +└─────────────┘ └──────────────────────┘ └─────────────┘ + ↓ + 401 Unauthorized + 403 Forbidden (required claims missing) ``` +The full design — token validation rules, required claims, and threat model — is documented in [AuthenticationDesign.md](AuthenticationDesign.md). + ## Configuration -> **Note**: The configuration examples below reference the [Physician sample extension](../physician/). Each product's sample follows the same authentication pattern. +> **Note:** The configuration examples below reference the [Physician sample extension](../physician/). Each product's sample follows the same authentication pattern. ### Development Environment -The development configuration in file [appsettings.Development.json](../physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/appsettings.Development.json) disables both authentication layers for ease of testing: +The development configuration in [appsettings.Development.json](../physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/appsettings.Development.json) disables authentication for ease of testing: - **Authentication**: Disabled -- **License Key Validation**: Disabled -**Purpose**: Allows easy testing and development without authentication overhead. +**Purpose:** allow easy local testing and development without authentication overhead. Never ship a configuration that disables authentication to production. ### Production Environment -The production configuration in file [appsettings.json](../physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/appsettings.json) enables full security: +The production configuration in [appsettings.json](../physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/appsettings.json) enables JWT authentication: - **Authentication**: Enabled with Microsoft Entra ID integration -- **License Key Validation**: Enabled with configurable header name and valid keys -**Required Production Settings**: +**Required production settings:** -- `TenantId`: Your organization's Entra ID tenant identifier -- `ClientId`: The registered application identifier in Entra ID -- `Instance`: The Entra ID authority URL (typically `https://login.microsoftonline.com/`) -- `RequiredClaims`: Array of required claims for authorization (e.g., `["idtyp", "azp"]`) -- `HeaderName`: Custom header name for license keys (e.g., `"X-License-Key"`) -- `ValidKeys`: Array of valid license key values +- `TenantId` — your organization's Entra ID tenant identifier +- `ClientId` — the registered application identifier in Entra ID +- `Instance` — the Entra ID authority URL (typically `https://login.microsoftonline.com/`) +- `RequiredClaims` — claims required for authorization (e.g. `idtyp`, `azp`) -> **CLI Note**: When you generate Clinical App Connector or Physician Workflow manifests with the `dragon-copilot` CLI, the `auth.tenantId` field captured in `extension.yaml` must match the `TenantId` configured here. The CLI wizard will prompt for the same tenant information to keep runtime and manifest settings aligned. +> **CLI Note:** When you generate Clinical App Connector or Physician Workflow manifests with the `dragon-copilot` CLI, the `auth.tenantId` field captured in `extension.yaml` must match the `TenantId` configured here. The CLI wizard prompts for the same tenant information to keep runtime and manifest settings aligned. ## Microsoft Entra ID (JWT) Authentication -### Implementation Features +### Implementation features -1. **Microsoft Identity Web Integration**: Uses Microsoft's official library for seamless Entra ID integration -2. **Conditional Enablement**: Can be completely disabled for development environments -3. **Custom Authorization Policies**: Supports additional claim-based requirements beyond basic authentication -4. **Graceful Degradation**: When disabled, authorization policies automatically allow access +1. **Microsoft Identity Web integration** — uses Microsoft's official library for Entra ID integration. +2. **Conditional enablement** — can be completely disabled for development environments. +3. **Custom authorization policies** — supports additional claim-based requirements beyond basic authentication. +4. **Graceful degradation** — when disabled, authorization policies automatically allow access. -### Usage in Controllers +### Usage in controllers Controllers use the `[Authorize]` attribute with custom policies to enforce JWT authentication and any additional claim requirements. -## License Key Validation - -### Purpose - -The license key system demonstrates how to implement **custom business authorization** beyond identity verification. This pattern can represent: - -- **Subscription tiers** (basic, premium, enterprise) -- **Feature flags** (access to specific functionality) -- **Partner access** (different API rate limits or features) -- **Usage quotas** (track and limit API calls per key) - -### Implementation Features - -1. **Conditional Processing**: Only runs when enabled in configuration -2. **Configurable Header**: Header name is fully configurable -3. **Structured Error Responses**: Returns consistent JSON error messages -4. **Context Storage**: Makes validated license key available to downstream controllers -5. **Multiple Valid Keys**: Supports different license types and values - -### Key Features - -- Validates license keys from HTTP headers -- Returns 403 Forbidden for invalid or missing keys -- Stores validated keys in the request context for downstream use -- Supports multiple valid keys for different access levels - ## Route Protection Strategy -### Public vs Protected Routes +### Public vs protected routes -The system defines specific routes that bypass all security checks: +The sample defines specific routes that bypass authentication: - **Health endpoints**: `/health` and `/v1/health` for monitoring - **Swagger UI**: `/index.html` for API documentation (development only) -All other routes require both JWT authentication and license key validation when enabled. +All other routes require JWT authentication when enabled. -### Conditional Security Application +### Conditional security application -Security middleware is applied selectively using route filtering: +Authentication middleware is applied selectively using route filtering: -- **Public routes**: Bypass all security checks -- **Protected routes**: Require JWT authentication first, then license key validation -- **Order matters**: JWT validation occurs before license key validation +- **Public routes** — bypass authentication. +- **Protected routes** — require JWT authentication and claim-based authorization. ## API Response Codes @@ -118,46 +85,30 @@ Security middleware is applied selectively using route filtering: | **200** | Success | Request processed successfully | | **400** | Bad Request | Invalid payload or parameters | | **401** | Unauthorized | JWT authentication failed or missing | -| **403** | Forbidden | JWT required claims missing, License key validation failed | +| **403** | Forbidden | Required claims missing | | **500** | Internal Server Error | Processing exception | ## Swagger Integration -The API documentation automatically includes security schemes for both authentication methods when enabled: +The API documentation automatically includes the Bearer security scheme when authentication is enabled: -- **Bearer Authentication**: JWT token in Authorization header -- **License Key**: Custom header for license key validation +- **Bearer Authentication** — JWT token in the `Authorization` header. -Security requirements are dynamically added based on configuration, ensuring the documentation accurately reflects the current security setup. +The security requirement is added dynamically based on configuration, so the documentation reflects the current setup. ## Usage Examples -### Development (No Authentication) - -Simple requests without any authentication headers work in development mode. +### Development (no authentication) -### Production (Full Authentication) +Requests without an `Authorization` header work in development mode. -Production requests require both JWT tokens and license keys: +### Production (JWT authentication) -- **Authorization header**: Bearer token from Entra ID -- **License key header**: Valid license key value - -## Extension Points - -This architecture provides several extension opportunities: - -1. **Enhanced License Logic**: Replace simple key validation with database lookups, expiration checks, feature flags -2. **Rate Limiting**: Implement different limits per license tier -3. **Audit Logging**: Track API usage per license key -4. **Multi-tenant Support**: Route requests based on license key to different processing logic +Production requests must include a valid bearer token in the `Authorization` header. The token is acquired by the Dragon Copilot Extension Runtime against your extension's Entra ID app registration. ## Security Best Practices -1. **Defense in Depth**: Multiple security layers provide redundancy -2. **Graceful Degradation**: System works in development without security overhead -3. **Structured Errors**: Consistent error responses don't leak implementation details -4. **Configuration-Driven**: Security can be toggled without code changes -5. **Standards Compliance**: Uses standard JWT and HTTP authentication patterns - -This dual-layer security approach provides both enterprise identity integration and flexible business logic enforcement, making it suitable for various deployment scenarios and business requirements. +1. **Standards compliance** — uses standard JWT and HTTP authentication patterns. +2. **Graceful degradation** — system works in development without authentication overhead. +3. **Structured errors** — consistent error responses do not leak implementation details. +4. **Configuration-driven** — authentication can be toggled without code changes. diff --git a/doc/AuthenticationDesign.md b/doc/AuthenticationDesign.md index 876db268..23ccb707 100644 --- a/doc/AuthenticationDesign.md +++ b/doc/AuthenticationDesign.md @@ -2,7 +2,7 @@ ## Overview -The Dragon Copilot Extension Runtime supports a multi-layered security approach combining **Microsoft Entra ID authentication** for service-to-service request security with **custom license key validation** for customer authorization. This design provides both enterprise-grade identity verification and flexible business logic enforcement. +The Dragon Copilot Extension Runtime authenticates service-to-service requests to extensions using **Microsoft Entra ID JWT bearer tokens**. This document describes the detailed Entra Id configuration and token validation required to protect against the identified threats. @@ -10,6 +10,8 @@ Additionally, you will find the threat model applied to the service-to-service r See [Authentication.md](Authentication.md) for implementation details in the Sample Extension. +> **Hitting auth errors?** See [Troubleshooting-Authentication.md](Troubleshooting-Authentication.md) for a symptom → cause matrix, FAQ, and pre-flight checklist covering the most common partner issues (`InvalidAudience`/`InvalidIssuer`, `identifierUris` mistakes, `AADSTS500011`, and more). + > **Related tooling**: The `dragon-copilot` CLI generates manifest version 3 files that capture the same tenant identifiers discussed here. Ensure the values provided to the CLI match the configuration steps below so request validation succeeds. ## Service-to-Service Authentication Overview diff --git a/doc/Troubleshooting-Authentication.md b/doc/Troubleshooting-Authentication.md new file mode 100644 index 00000000..c0936c3a --- /dev/null +++ b/doc/Troubleshooting-Authentication.md @@ -0,0 +1,249 @@ +# Troubleshooting Authentication for Dragon Copilot Extensions + +**Audience:** Developers building and operating Dragon Copilot extensions. +**Scope:** Service-to-service authentication between the Dragon Copilot Extension Runtime and a partner-hosted extension endpoint — JWT/Microsoft Entra ID, Application ID URI / `identifierUris`, and manifest configuration. + +**See also:** [Authentication.md](Authentication.md) · [AuthenticationDesign.md](AuthenticationDesign.md) + +--- + +## How to use this guide + +1. Find your symptom in the [Symptom → Likely Cause matrix](#1-symptom--likely-cause-matrix). +2. Jump to the matching deep-dive in [Section 2](#2-deep-dives). +3. Before opening a support ticket, work through the [Pre-flight checklist](#4-pre-flight-checklist) and gather the [diagnostic bundle](#5-diagnostic-bundle). + +--- + +## 1. Symptom → Likely Cause matrix + +| Symptom | Most likely cause | Jump to | +|---|---|---| +| `401 Unauthorized` with `invalid_token` / `InvalidAudience` | `aud` claim does not match your extension's Client ID | [2.1](#21-401--invalidaudience) | +| `401 Unauthorized` with `InvalidIssuer` | `iss` claim does not match `https://login.microsoftonline.com/{your-tenant-id}/` | [2.2](#22-401--invalidissuer) | +| `401 Unauthorized` with `InvalidApplicationId` | `azp` claim is not the Dragon Copilot Runtime's client ID, or `idtyp` ≠ `app` | [2.3](#23-401--invalidapplicationid--idtyp--azp-checks) | +| Extension is deployed but receives no traffic | Manifest endpoint/auth misconfigured, or a WAF / firewall / network ACL is dropping the Runtime's calls | [2.4](#24-no-traffic--silent-failure) | +| `AADSTS500011: resource principal not found in tenant` | Dragon Copilot Runtime service principal is not provisioned in your tenant | [2.5](#25-aadsts500011-resource-principal-not-found) | +| "Which JWT claim identifies the customer?" | Customer/tenant identity model question (multi-tenant routing) | [2.6](#26-which-claim-identifies-the-customer) | +| `InvalidAudience` even though Client ID looks correct | Application ID URI (`identifierUris`) host does not match the extension endpoint host | [2.7](#27-application-id-uri--identifieruris-mistakes) | + +--- + +## 2. Deep dives + +### 2.1 `401` / `InvalidAudience` + +Your extension validated the JWT signature, but the `aud` claim does not match what your validator expects. + +**Checks (in order):** +1. Decode the token at and read the `aud` claim. +2. Confirm your extension's Entra ID app registration has `requestedAccessTokenVersion: 2` in its manifest. Without this, `aud` will contain the Application ID URI instead of the Client ID GUID. See [AuthenticationDesign.md](AuthenticationDesign.md#configure-your-extensions-entra-id-application-registration). +3. Confirm your validator accepts the **Client ID (GUID)** of your extension's app registration as the expected `aud`. +4. Confirm the Application ID URI is `api://{your-tenant-id}/{extension-endpoint-hostname}` and matches the hostname the Runtime is calling. See [§2.7](#27-application-id-uri--identifieruris-mistakes). + +**Fix:** Switch the app registration to v2 tokens, or align the validator's expected audience with what is actually issued. + +--- + +### 2.2 `401` / `InvalidIssuer` + +The `iss` claim does not match the tenant your extension trusts. + +**Checks:** +1. `iss` must be exactly `https://login.microsoftonline.com/{your-tenant-id}/` — note the trailing slash and v2.0 endpoint format. +2. Confirm the tenant ID configured in your JWT validator matches the `auth.tenantId` in your `extension.yaml` manifest. The two values **must** match — the `dragon-copilot` CLI prompts for the same value to keep them aligned. +3. Do not use the `common` or `organizations` authority for a single-tenant extension. Pin the validator to your tenant GUID. + +--- + +### 2.3 `401` / `InvalidApplicationId` / `idtyp` / `azp` checks + +The token your extension received is not from the Dragon Copilot Extension Runtime. + +**Required claims** (see [AuthenticationDesign.md](AuthenticationDesign.md#extension-responsibilities)): + +- `idtyp` == `"app"` +- `azp` == `d9350f5d-71c2-46b9-b41d-3c5d51ffe6e8` (Dragon Copilot Extension Runtime Client ID) + +**Common mistakes:** +- The `idtyp` optional claim is not configured on the app registration. It must be added explicitly. +- Validating `appid` instead of `azp` — v2 tokens use `azp`. +- Hard-coding a different runtime client ID copied from outdated material. `d9350f5d-71c2-46b9-b41d-3c5d51ffe6e8` is the authoritative value. + +--- + +### 2.4 No traffic / silent failure + +Symptom: the extension is deployed and "auth is configured," but no requests ever arrive at the endpoint. + +**Checks (in order):** +1. **Manifest auth block.** In `extension.yaml`, verify `auth.tenantId` is the **partner tenant GUID** (the tenant issuing and validating tokens), not the customer's tenant. +2. **Endpoint reachability.** The endpoint in your manifest must be a publicly reachable HTTPS URL with a valid (non-self-signed) TLS certificate. The Runtime will not call HTTP endpoints. +3. **WAF / firewall / network ACLs.** A Web Application Firewall, NSG, private endpoint, IP allowlist, or upstream proxy in front of your extension can silently drop the Runtime's calls — often before they reach your application logs. Check at the edge first: + - Look at WAF / firewall / load balancer logs for blocked or dropped requests around the time you expect traffic. + - Confirm the path (`POST` to your `/v1/process` endpoint) and `Authorization` header are not being stripped or normalized by an intermediate proxy. + - Common managed-WAF rule sets (Azure Front Door / Application Gateway default rules, Cloudflare managed rules) sometimes flag the request body or bearer header as suspicious. Temporarily lower the rule set or whitelist the Runtime's traffic to confirm. + - If your endpoint is in a VNet behind a private endpoint or IP allowlist, the Runtime's egress is not on a fixed IP range — you cannot allowlist it. Expose the endpoint via a public ingress (Azure API Management, Azure Front Door, Application Gateway, or Container Apps public ingress). +4. **Trigger configuration.** If `trigger: AdaptiveCardAction`, the extension only fires on user action — it will not auto-run. + +--- + +### 2.5 `AADSTS500011`: resource principal not found + +The Dragon Copilot Extension Runtime's service principal has not been provisioned into the tenant performing the token acquisition. + +**Fix (one-time per tenant):** + +- **Preferred:** Register the `Microsoft.HealthPlatform` resource provider in any Azure subscription bound to that tenant. This automatically injects the service principal. +- **Alternative:** A tenant admin runs: + ```powershell + New-MgServicePrincipal -AppId d9350f5d-71c2-46b9-b41d-3c5d51ffe6e8 + ``` + +See [AuthenticationDesign.md §Add the Service Principal to your tenant](AuthenticationDesign.md#add-the-service-principal-to-your-tenant). + +--- + +### 2.6 Which claim identifies the customer? + +Common question for partners building multi-tenant extensions: which JWT claim identifies *which customer* the request belongs to? + +**Current model:** +- Extension app registrations are **single-tenant** (the partner's tenant). The `iss`, `aud`, and `azp` claims therefore identify the platform calling you — not the end customer. +- **Customer/tenant identity is conveyed in the request payload** (`DragonStandardPayload` — session, encounter, practitioner context), not in the JWT. +- For stable customer routing, key off identifiers in the payload (for example, practitioner tenant identifier, encounter context). + +If your scenario truly requires a customer-scoped JWT claim, raise it with your Microsoft contact. + +--- + +### 2.7 Application ID URI / `identifierUris` mistakes + +This is one of the most common sources of `InvalidAudience` failures after partners believe everything else is configured correctly. + +**Mental model:** the platform does not "send" an audience. It acquires a token using `identifierUri` **as the scope**, and the resulting `aud` claim is derived from it. If `identifierUri` does not line up with the actual endpoint host, the token your extension receives will have an `aud` that cannot match a sensible validation rule. + +**Canonical format:** + +``` +api://{your-tenant-id}/{exact-endpoint-hostname} +``` + +**Common mistakes:** + +1. **Host in `identifierUri` does not match the endpoint host in `extension.yaml`.** + - Manifest endpoint: `https://func-xyz.azurewebsites.net/v1/process` + - `identifierUris`: `api://{tenant}/api.mypartner.com` ← wrong host + - **Fix:** the hosts must be byte-for-byte identical. + +2. **Using the App ID (clientId) instead of the tenant ID in the URI.** + - Wrong: `api://{clientId-guid}/{host}` + - Right: `api://{tenantId-guid}/{host}` + +3. **Including a path or port in the URI.** + - Wrong: `api://{tenant}/api.mypartner.com/v1/process` + - Wrong: `api://{tenant}/api.mypartner.com:8443` + - Right: `api://{tenant}/api.mypartner.com` — **host only**, no path, port, or query. + +4. **Re-using one hostname across environments.** + - The hostname **is** the environment discriminator (path is not part of `aud`). + - Use distinct hosts per environment: `dev.api.mypartner.com`, `staging.api.mypartner.com`, `api.mypartner.com`. + +5. **Forgetting that an app registration can hold multiple `identifierUris`.** + - For non-prod consolidation, add multiple entries to the `identifierUris` array in the app registration manifest: + ```json + "identifierUris": [ + "api://{tenant}/dev.api.mypartner.com", + "api://{tenant}/staging.api.mypartner.com" + ] + ``` + - **For production, prefer a separate app registration** to prevent cross-environment token reuse. + +6. **Dev tunnel / temporary hostnames updated in the manifest but not in `identifierUris`.** + - Common during local development with dev tunnels, ngrok, or VS Code Tunnels. + - When the tunnel host changes, update **both** the `endpoint` in `extension.yaml` *and* the `identifierUris` array. Otherwise the next inbound call fails with `InvalidAudience`. + +7. **Wildcard URIs (e.g. `api://{tenant}/*.mypartner.com`).** + - Not supported. Enumerate each concrete host you need. + +**Quick validation procedure:** + +```text +1. Read the endpoint host from extension.yaml (tools[].endpoint). +2. Read the identifierUris array from your Entra app registration manifest. +3. Confirm the array contains exactly: api://{your-tenant-id}/{that-host}. +4. Decode a real token at https://jwt.ms and confirm aud equals that string. +``` + +**Validation rule for your extension code:** +Reject the request if `aud` does not equal `api://{your-tenant-id}/{Host header of the request}`. This prevents accidental cross-environment token replay. + +--- + +## 3. FAQ + +**Q: Can I disable authentication for local development?** +Yes. The reference sample includes a development configuration that disables the JWT gate for ease of testing. Never ship a configuration that disables auth to production. See [Authentication.md §Development Environment](Authentication.md#development-environment). + +**Q: Which Entra ID token version should I use?** +**v2.** Set `requestedAccessTokenVersion: 2` in your app registration manifest. v1 tokens place the Application ID URI in `aud`, which produces confusing `InvalidAudience` errors against validators expecting the Client ID GUID. + +**Q: What is the Dragon Copilot Extension Runtime Client ID?** +`d9350f5d-71c2-46b9-b41d-3c5d51ffe6e8`. Validate this as `azp` on incoming tokens. + +**Q: Does my extension's app registration need to be multi-tenant?** +No — **single-tenant** is correct and recommended. The platform calls your extension with a token issued by your tenant. + +**Q: My endpoint is behind a private network / VNet. Can the Runtime reach it?** +The endpoint URL in the manifest must be publicly reachable HTTPS. Use a public ingress (Azure API Management, Azure Front Door, Application Gateway, or Container Apps with public ingress). + +**Q: Are there TLS requirements?** +Yes — HTTPS with TLS 1.2 or higher, server certificate validated by a public CA. Self-signed certificates are rejected. + +**Q: How do I see the actual token the Runtime sent me?** +Log the raw `Authorization` header at the first middleware, before validation runs. **Strip the token from logs in production** — treat it as a secret. For debugging, decode at . + +**Q: My `extension.yaml` `auth.tenantId` and the tenant ID in my JWT validator — must they match?** +Yes. The CLI prompts for the same value precisely to keep them aligned. A mismatch is a common source of `InvalidIssuer` errors. + +**Q: What exactly goes in the Application ID URI (`identifierUris`)?** +The format is `api://{your-tenant-id}/{endpoint-hostname}` — host only, no path, no port, no wildcards, and use the **tenant ID** (not the client ID). The host must match the hostname of your extension endpoint in `extension.yaml` exactly. See [§2.7](#27-application-id-uri--identifieruris-mistakes). + +**Q: How do I handle dev / staging / prod environments?** +Two supported patterns: +- **(a)** One app registration with multiple `identifierUris` (one per environment hostname). Good for non-prod consolidation. +- **(b)** Separate app registrations per environment. Preferred for production isolation so tokens cannot be replayed across environments. + +**Q: My dev tunnel hostname keeps changing. What do I update?** +Both the `endpoint` in `extension.yaml` **and** the `identifierUris` array on your Entra ID app registration. This is easy to forget and manifests as `InvalidAudience` on the very next call. + +--- + +## 4. Pre-flight checklist + +Work through this list before opening a support ticket. + +- [ ] Decoded a real failing token at and captured `iss`, `aud`, `azp`, `idtyp`, `exp`. +- [ ] Confirmed `iss` == `https://login.microsoftonline.com/{my-tenant-id}/`. +- [ ] Confirmed `aud` == my extension's Client ID (GUID), and my app registration uses v2 tokens. +- [ ] Confirmed `identifierUris` on the app registration includes `api://{my-tenant-id}/{endpoint-hostname}` matching the host in `extension.yaml`. +- [ ] Confirmed `azp` == `d9350f5d-71c2-46b9-b41d-3c5d51ffe6e8` and `idtyp` == `app`. +- [ ] Confirmed `auth.tenantId` in `extension.yaml` matches the tenant ID configured in my JWT validator. +- [ ] Confirmed the Dragon Copilot Runtime service principal exists in my tenant (no `AADSTS500011`). +- [ ] Confirmed the extension endpoint is public HTTPS with a valid TLS certificate. +- [ ] Checked WAF / firewall / proxy / load balancer logs for dropped or blocked requests from the Runtime (and confirmed the `Authorization` header is not being stripped upstream). + +--- + +## 5. Diagnostic bundle + +Attach the following when opening a support ticket to accelerate resolution: + +1. **Dragon session correlation ID** — the correlation/session ID shown in the Dragon UI for the session in which the failure occurred. This is the primary identifier the platform team uses to trace the failing request through Runtime logs. +2. **Approximate timestamp** of the failure (date and rough time-of-day in your local time zone is sufficient). +3. **Decoded JWT** (header and payload only — never include the signature) of the failing token. +4. **Exact HTTP status code and response body** your extension returned. +5. **Excerpts from `extension.yaml`** showing the `auth` block and `tools[].endpoint`. +6. **Your extension's auth configuration** — the tenant ID, expected audience, expected issuer, and any required claims your JWT validator enforces. +7. **Your tenant ID** and **extension Client ID (GUID)**. +8. Whether the issue is **100% reproducible or intermittent**, and the approximate failure rate. diff --git a/physician/QUICKSTART.md b/physician/QUICKSTART.md index 3ca0b7d5..3f8f06ee 100644 --- a/physician/QUICKSTART.md +++ b/physician/QUICKSTART.md @@ -22,6 +22,8 @@ This document is a quick‑start guide for building, testing, packaging, and dep Prior to running through this document, you may want to read through the Microsoft Learn [documentation](https://learn.microsoft.com/en-us/industry/healthcare/dragon-copilot/extensions/workflow-app-overview) outlining how your extension will interface with the overall Dragon Copilot solution. +> **Hitting auth errors?** See [Troubleshooting-Authentication.md](../doc/Troubleshooting-Authentication.md) for a symptom → cause matrix, FAQ, and pre-flight checklist covering the most common partner issues (`InvalidAudience`/`InvalidIssuer`, `identifierUris` mistakes, `AADSTS500011`, and more). + ## Extensions vs. Clinical Application Connectors Dragon Copilot supports two types of integrations: @@ -86,6 +88,8 @@ Transfer-Encoding: chunked Details about an adaptive card structure and what fields are valid is located in the [Adaptive Card Specification](https://learn.microsoft.com/en-us/industry/healthcare/dragon-copilot/extensions/adaptive-card-spec). +> **Tip:** Validate the adaptive card your extension generates against what Dragon Copilot accepts using the [Adaptive Card Validator](https://cardvalidator.copilot.dragon.com/). Paste in your card JSON to catch unsupported elements, schema-version mismatches, and other issues before testing end-to-end. + ### Making Code Changes The majority of the code changes for your extension should fall underneath the [Process API](./physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Controllers/ProcessController.cs#L58-L95) method. The Process API will be called by Dragon Copilot to execute your extension. diff --git a/physician/src/models/Dragon.Copilot.Physician.Models/AdaptiveCardPayload.cs b/physician/src/models/Dragon.Copilot.Physician.Models/AdaptiveCardPayload.cs index e1cef0d7..649c852a 100644 --- a/physician/src/models/Dragon.Copilot.Physician.Models/AdaptiveCardPayload.cs +++ b/physician/src/models/Dragon.Copilot.Physician.Models/AdaptiveCardPayload.cs @@ -9,6 +9,10 @@ namespace Dragon.Copilot.Physician.Models; /// /// Adaptive card payload structure /// +/// +/// Validate generated cards against what Dragon Copilot accepts using the +/// Adaptive Card Validator: https://cardvalidator.copilot.dragon.com/. +/// public class AdaptiveCardPayload { /// diff --git a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Configuration/KnownRoutes.cs b/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Configuration/KnownRoutes.cs index eec83746..83f2dbe0 100644 --- a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Configuration/KnownRoutes.cs +++ b/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Configuration/KnownRoutes.cs @@ -12,7 +12,7 @@ namespace SampleExtension.Web.Configuration; public static class KnownRoutes { /// - /// Public routes (no authentication or license key required) + /// Public routes (no authentication required) /// private const string Health = "/health"; // Mapped in Program.cs private const string HealthV1 = "/v1/health"; // Controller endpoint diff --git a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Configuration/LicenseKeyOptions.cs b/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Configuration/LicenseKeyOptions.cs deleted file mode 100644 index 0540e1bd..00000000 --- a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Configuration/LicenseKeyOptions.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Collections.Generic; - -namespace SampleExtension.Web.Configuration; - -/// -/// License key validation configuration options -/// -public class LicenseKeyOptions -{ - /// - /// The configuration section name - /// - public const string SectionName = "LicenseKey"; - - /// - /// Whether license key validation is enabled - /// - public bool Enabled { get; set; } - - /// - /// Header name containing the license key - /// - public string? HeaderName { get; set; } - - /// - /// Array of valid license keys - /// - public ICollection ValidKeys { get; } = new HashSet(); -} diff --git a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Controllers/ProcessController.cs b/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Controllers/ProcessController.cs index cab1e9b4..9341301c 100644 --- a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Controllers/ProcessController.cs +++ b/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Controllers/ProcessController.cs @@ -46,7 +46,7 @@ public ProcessController(IProcessingService processingService, ILoggerSuccessfully processed /// Bad request /// Unauthorized - JWT authentication failed - /// Forbidden - License key validation failed + /// Forbidden - required claims missing /// Internal server error [HttpPost("process")] [Authorize(Policy = "RequiredClaims")] // JWT + Claims validation (framework handles this) diff --git a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Extensions/ServiceCollectionExtensions.cs b/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Extensions/ServiceCollectionExtensions.cs index a2577bb4..947c50ee 100644 --- a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Extensions/ServiceCollectionExtensions.cs +++ b/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Extensions/ServiceCollectionExtensions.cs @@ -175,7 +175,6 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection // Configure options services.Configure(configuration.GetSection(AuthenticationOptions.SectionName)); - services.Configure(configuration.GetSection(LicenseKeyOptions.SectionName)); return services; } diff --git a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Extensions/WebApplicationExtensions.cs b/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Extensions/WebApplicationExtensions.cs index 2d20b0a1..804c969c 100644 --- a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Extensions/WebApplicationExtensions.cs +++ b/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Extensions/WebApplicationExtensions.cs @@ -7,7 +7,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.OpenApi; using SampleExtension.Web.Configuration; -using SampleExtension.Web.Middleware; namespace SampleExtension.Web.Extensions; @@ -17,23 +16,19 @@ namespace SampleExtension.Web.Extensions; internal static class WebApplicationExtensions { /// - /// Applies full security (JWT + License Key) to all non-public routes + /// Applies JWT authentication and authorization to all non-public routes /// /// The web application /// The web application internal static WebApplication UseFullSecurity(this WebApplication app) { - // Apply both JWT authentication and license key validation to all non-public routes + // Apply JWT authentication and authorization to all non-public routes app.UseWhen( context => !KnownRoutes.IsPublicRoute(context.Request.Path.Value ?? string.Empty), protectedBranch => { - // First: JWT Authentication & Authorization protectedBranch.UseAuthentication(); protectedBranch.UseAuthorization(); - - // Second: License Key Validation (after JWT is validated) - protectedBranch.UseMiddleware(); }); return app; @@ -74,29 +69,6 @@ internal static WebApplicationBuilder AddSwaggerConfiguration(this WebApplicatio }; }); } - - // Add license key header to Swagger - var licenseOptions = webApplicationBuilder.Configuration.GetSection(LicenseKeyOptions.SectionName).Get(); - if (licenseOptions?.Enabled == true) - { - c.AddSecurityDefinition("LicenseKey", new OpenApiSecurityScheme - { - Description = $"License key header for protected routes. Example: \"{licenseOptions.HeaderName}: your-license-key\"", - Name = licenseOptions.HeaderName, - In = ParameterLocation.Header, - Type = SecuritySchemeType.ApiKey - }); - - c.AddSecurityRequirement(document => - { - OpenApiSecuritySchemeReference schemeRef = new("LicenseKey", document); - - return new OpenApiSecurityRequirement - { - [schemeRef] = new List() - }; - }); - } }); return webApplicationBuilder; diff --git a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Middleware/LicenseKeyMiddleware.cs b/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Middleware/LicenseKeyMiddleware.cs deleted file mode 100644 index 555f88fe..00000000 --- a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Middleware/LicenseKeyMiddleware.cs +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System; -using System.Diagnostics; -using System.Linq; -using System.Text.Json; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using SampleExtension.Web.Configuration; - -namespace SampleExtension.Web.Middleware; - -/// -/// License key validation middleware -/// Only applied to protected routes (non-public routes) -/// -public class LicenseKeyMiddleware -{ - private readonly RequestDelegate _next; - private readonly LicenseKeyOptions _licenseKeyOptions; - private readonly ILogger _logger; - - /// - /// Constructor for LicenseKeyMiddleware - /// - /// The next middleware in the pipeline - /// License key options - /// Logger - public LicenseKeyMiddleware(RequestDelegate next, IOptions options, ILogger logger) - { - ArgumentNullException.ThrowIfNull(next, nameof(next)); - ArgumentNullException.ThrowIfNull(options?.Value, nameof(options)); - - _next = next; - _licenseKeyOptions = options.Value; - _logger = logger; - } - - /// - /// Invokes the middleware - /// - /// The HTTP context - /// A task - public async Task InvokeAsync(HttpContext context) - { - ArgumentNullException.ThrowIfNull(context, nameof(context)); - - // Skip if license key validation is globally disabled - if (!_licenseKeyOptions.Enabled) - { - await _next(context).ConfigureAwait(false); - return; - } - - Debug.Assert(_licenseKeyOptions.HeaderName != null); - var licenseKey = context.Request.Headers[_licenseKeyOptions.HeaderName].FirstOrDefault(); - - if (string.IsNullOrEmpty(licenseKey) || !_licenseKeyOptions.ValidKeys.Contains(licenseKey)) - { - context.Response.StatusCode = StatusCodes.Status403Forbidden; - context.Response.ContentType = "application/json"; - - var response = new - { - success = false, - error = "Invalid or missing license key", - timestamp = DateTime.UtcNow - }; - - var jsonResponse = JsonSerializer.Serialize(response, JsonSerializerOptions.Web); - - await context.Response.WriteAsync(jsonResponse).ConfigureAwait(false); - return; - } - - // Store validated license key in context for potential use downstream - context.Items["ValidatedLicenseKey"] = licenseKey; - - await _next(context).ConfigureAwait(false); - } -} diff --git a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Program.cs b/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Program.cs index 3acd31cc..c6454419 100644 --- a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Program.cs +++ b/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Program.cs @@ -63,7 +63,7 @@ app.UseHttpsRedirection(); app.UseCors(); -// Apply full security (JWT + License Key) to all non-public routes +// Apply JWT authentication and authorization to all non-public routes app.UseFullSecurity(); app.MapControllers(); diff --git a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/appsettings.Development.json b/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/appsettings.Development.json index 0f3e1a59..6c7ba445 100644 --- a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/appsettings.Development.json +++ b/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/appsettings.Development.json @@ -12,8 +12,5 @@ }, "Authentication": { "Enabled": false - }, - "LicenseKey": { - "Enabled": false } } diff --git a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/appsettings.json b/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/appsettings.json index e360eb56..527b5b06 100644 --- a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/appsettings.json +++ b/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/appsettings.json @@ -22,10 +22,5 @@ "idtyp": ["app"], "azp": ["d9350f5d-71c2-46b9-b41d-3c5d51ffe6e8"] // This represents Microsoft Dragon Copilot Extensions Runtime Application } - }, - "LicenseKey": { - "Enabled": true, - "HeaderName": "X-License-Key", - "ValidKeys": ["premium-key-1", "enterprise-key-2", "partner-key-3"] } } diff --git a/physician/src/samples/DragonCopilot/Workflow/pythonSampleExtension/README.md b/physician/src/samples/DragonCopilot/Workflow/pythonSampleExtension/README.md index 30a0389f..dfab30e1 100644 --- a/physician/src/samples/DragonCopilot/Workflow/pythonSampleExtension/README.md +++ b/physician/src/samples/DragonCopilot/Workflow/pythonSampleExtension/README.md @@ -156,6 +156,9 @@ You shall see the workflow sample server returns response similar to the followi } } ``` + +> **Tip:** Validate the `adaptive_card_payload` your extension produces against what Dragon Copilot accepts using the [Adaptive Card Validator](https://cardvalidator.copilot.dragon.com/). Paste in your card JSON to catch unsupported elements, schema-version mismatches, and other issues before testing end-to-end. + --- ## 6. Deploying Your Extension From 56d898fe36a0956c4b829105b756c35da7a458be Mon Sep 17 00:00:00 2001 From: brandonpollett Date: Thu, 11 Jun 2026 20:29:44 -0400 Subject: [PATCH 2/4] Fix sample adaptive card so it renders in the Dragon Copilot validator The validator preview pane silently failed (Invalid Card) on the sample's output even though structural validation reported Valid JSON. Two issues: 1. AdaptiveCardPayload.Version was hard-coded to "1.3". The Dragon Copilot card spec does not require ersion; the validator's reference sample omits it entirely. Made Version nullable and tagged it with JsonIgnore(Condition = WhenWritingNull) so it is omitted by default. Partners can still set a specific version if they need one. 2. VisualizationResource.References was being emitted as [] (empty array). The validator treats an empty eferences array as a render- blocker (silent fail) and a missing eferences field as a hard error (Missing property "references"). Populated the sample with a Web reference pointing at the published adaptive-card-spec page so partners see a working reference end-to-end. Also tagged the nullable string fields on VisualizationReference (SectionId, Text, Title, Url) with JsonIgnore(WhenWritingNull) so a Web-typed reference no longer serializes sectionId: null / ext: null (which the validator flags as "Property X is not allowed"). XML doc updates: - AdaptiveCardPayload: validator link now also notes the aka.ms shortlink and links to the official adaptive-card-spec. - AdaptiveCardPayload.Version: documents that the field is optional and should normally be omitted. - VisualizationResource.References: warns that an empty array causes the validator preview to fail silently and links to the spec. Verified end-to-end against https://cardvalidator.copilot.dragon.com/: status Valid JSON, zero warnings, full preview renders (title, all five action buttons, references footer). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AdaptiveCardPayload.cs | 11 ++++++++--- .../VisualizationReference.cs | 4 ++++ .../VisualizationResource.cs | 5 ++++- .../Services/ProcessingService.cs | 12 ++++++++++-- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/physician/src/models/Dragon.Copilot.Physician.Models/AdaptiveCardPayload.cs b/physician/src/models/Dragon.Copilot.Physician.Models/AdaptiveCardPayload.cs index 649c852a..cc70ce69 100644 --- a/physician/src/models/Dragon.Copilot.Physician.Models/AdaptiveCardPayload.cs +++ b/physician/src/models/Dragon.Copilot.Physician.Models/AdaptiveCardPayload.cs @@ -11,7 +11,9 @@ namespace Dragon.Copilot.Physician.Models; /// /// /// Validate generated cards against what Dragon Copilot accepts using the -/// Adaptive Card Validator: https://cardvalidator.copilot.dragon.com/. +/// Adaptive Card Validator: https://cardvalidator.copilot.dragon.com/ +/// (also reachable via https://aka.ms/adaptiveCardValidator). +/// Reference: https://learn.microsoft.com/en-us/industry/healthcare/dragon-copilot/extensions/adaptive-card-spec. /// public class AdaptiveCardPayload { @@ -28,10 +30,13 @@ public class AdaptiveCardPayload public string Type { get; set; } = "AdaptiveCard"; /// - /// Adaptive card version + /// Adaptive card version. Optional - omit (leave null) to let Dragon Copilot + /// apply its default supported version. Set explicitly only if you require a + /// specific Adaptive Cards schema version. /// [JsonPropertyName("version")] - public string Version { get; init; } = "1.3"; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Version { get; init; } /// /// Body elements of the adaptive card diff --git a/physician/src/models/Dragon.Copilot.Physician.Models/VisualizationReference.cs b/physician/src/models/Dragon.Copilot.Physician.Models/VisualizationReference.cs index 4afeb48a..323dedfd 100644 --- a/physician/src/models/Dragon.Copilot.Physician.Models/VisualizationReference.cs +++ b/physician/src/models/Dragon.Copilot.Physician.Models/VisualizationReference.cs @@ -28,23 +28,27 @@ public class VisualizationReference /// Section identifier for Note type references /// [JsonPropertyName("sectionId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? SectionId { get; set; } /// /// Reference text content for Transcript type references /// [JsonPropertyName("text")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Text { get; set; } /// /// Title for Web type references /// [JsonPropertyName("title")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Title { get; set; } /// /// URL for Web type references /// [JsonPropertyName("url")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public Uri? Url { get; set; } } diff --git a/physician/src/models/Dragon.Copilot.Physician.Models/VisualizationResource.cs b/physician/src/models/Dragon.Copilot.Physician.Models/VisualizationResource.cs index 87ea1060..ffb12ef3 100644 --- a/physician/src/models/Dragon.Copilot.Physician.Models/VisualizationResource.cs +++ b/physician/src/models/Dragon.Copilot.Physician.Models/VisualizationResource.cs @@ -48,7 +48,10 @@ public class VisualizationResource : IResource public required AdaptiveCardPayload AdaptiveCardPayload { get; set; } /// - /// References to related data sources + /// References to related data sources. Should contain at least one entry + /// for the card to render in the Dragon Copilot UI / validator preview; + /// an empty array causes the validator preview to fail silently. + /// See https://learn.microsoft.com/en-us/industry/healthcare/dragon-copilot/extensions/adaptive-card-spec. /// [JsonPropertyName("references")] public IList? References { get; init; } diff --git a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Services/ProcessingService.cs b/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Services/ProcessingService.cs index 5882d034..bfb43e99 100644 --- a/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Services/ProcessingService.cs +++ b/physician/src/samples/DragonCopilot/Workflow/SampleExtension.Web/Services/ProcessingService.cs @@ -319,7 +319,6 @@ private static VisualizationResource CreateAdaptiveCardResource(List AdaptiveCardPayload = new AdaptiveCardPayload { Type = "AdaptiveCard", - Version = "1.3", Body = bodyElements.ToArray(), Actions = new List { @@ -407,7 +406,16 @@ private static VisualizationResource CreateAdaptiveCardResource(List }, }, }, - References = new List(), + References = new List + { + new() + { + Id = Guid.NewGuid().ToString(), + Type = ReferenceType.Web, + Title = "Dragon Copilot adaptive card specification", + Url = new Uri("https://learn.microsoft.com/en-us/industry/healthcare/dragon-copilot/extensions/adaptive-card-spec") + } + }, PayloadSources = new List { new() From 30d8e4d22934a164d477f90d02bc718ca7454f68 Mon Sep 17 00:00:00 2001 From: brandonpollett Date: Thu, 11 Jun 2026 20:32:30 -0400 Subject: [PATCH 3/4] Fix python sample adaptive card so it renders in the Dragon Copilot validator Mirrors the C# fix in the previous commit. Without these changes the validator preview pane silently failed (`Invalid Card`) on the python sample's output even though structural validation reported `Valid JSON`. Changes in `app/service.py`: - Removed the hardcoded `"version": "1.6"` from the active adaptive card payload (and from the two commented-out alternate cards). The Dragon Copilot card spec does not require `version` and the validator's reference sample omits it. - Replaced `references=[]` with a `Web`-typed reference pointing at the published adaptive-card-spec page. The validator treats an empty `references` array as a render-blocker (silent fail). - Added inline comments at both sites explaining why `version` is omitted and why `references` must be non-empty. - Applied the same fix to the two commented-out example cards so any future re-enablement starts from a working baseline. Test update in `app/tests/test_adaptive_card.py`: - Replaced `assert ac.get("version") == "1.6"` with `assert "version" not in ac` to lock in the new behavior. Verified end-to-end against https://cardvalidator.copilot.dragon.com/: status `Valid JSON`, zero warnings, preview renders both action buttons and the references footer. All 5 pytest tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../pythonSampleExtension/app/service.py | 42 ++++++++++++++++--- .../app/tests/test_adaptive_card.py | 6 ++- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/physician/src/samples/DragonCopilot/Workflow/pythonSampleExtension/app/service.py b/physician/src/samples/DragonCopilot/Workflow/pythonSampleExtension/app/service.py index c342fe8c..93421599 100644 --- a/physician/src/samples/DragonCopilot/Workflow/pythonSampleExtension/app/service.py +++ b/physician/src/samples/DragonCopilot/Workflow/pythonSampleExtension/app/service.py @@ -176,7 +176,10 @@ def _adaptive_card(self, entities: List[Any]) -> models.VisualizationResource: adaptive_card_payload={ "type": "AdaptiveCard", "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - "version": "1.6", + # `version` is intentionally omitted. The Dragon Copilot adaptive + # card spec does not require it and the validator's reference + # sample omits it. Set it explicitly only if you require a + # specific Adaptive Cards schema version. "body": body, "actions": [ { @@ -208,7 +211,18 @@ def _adaptive_card(self, entities: List[Any]) -> models.VisualizationResource: ], dragonCopilotCopyData="Clinical entities extracted from note content", partnerLogo="https://contoso.com/logo.png", - references=[], + # `references` must contain at least one entry for the card to + # render in the Dragon Copilot UI / validator preview. An empty + # list causes the validator preview to fail silently. See + # https://learn.microsoft.com/en-us/industry/healthcare/dragon-copilot/extensions/adaptive-card-spec + references=[ + { + "id": str(uuid4()), + "type": "Web", + "title": "Dragon Copilot adaptive card specification", + "url": "https://learn.microsoft.com/en-us/industry/healthcare/dragon-copilot/extensions/adaptive-card-spec" + } + ], ) # NOTE: _composite_medication_summary and _timeline_card are not currently @@ -248,7 +262,7 @@ def _adaptive_card(self, entities: List[Any]) -> models.VisualizationResource: # adaptive_card_payload={ # "type": "AdaptiveCard", # "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - # "version": "1.6", + # # `version` intentionally omitted; see _adaptive_card above. # "body": body, # "actions": [ # { @@ -266,7 +280,15 @@ def _adaptive_card(self, entities: List[Any]) -> models.VisualizationResource: # {"identifier": str(uuid4()), "description": "Python Demo Medication Analysis Service", "url": "http://localhost:5181/v1/process"} # ], # dragonCopilotCopyData="medication_analysis|demo:1|generated:" + datetime.now(timezone.utc).isoformat(), - # references=[], + # # `references` must be non-empty; see _adaptive_card above. + # references=[ + # { + # "id": str(uuid4()), + # "type": "Web", + # "title": "Dragon Copilot adaptive card specification", + # "url": "https://learn.microsoft.com/en-us/industry/healthcare/dragon-copilot/extensions/adaptive-card-spec" + # } + # ], # partnerLogo="https://contoso.com/logo.png", # ) # @@ -288,7 +310,7 @@ def _adaptive_card(self, entities: List[Any]) -> models.VisualizationResource: # adaptive_card_payload={ # "type": "AdaptiveCard", # "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", - # "version": "1.6", + # # `version` intentionally omitted; see _adaptive_card above. # "body": body, # "actions": [ # { @@ -307,5 +329,13 @@ def _adaptive_card(self, entities: List[Any]) -> models.VisualizationResource: # ], # dragonCopilotCopyData="lab_timeline|demo:1|generated:" + datetime.now(timezone.utc).isoformat(), # partnerLogo="https://contoso.com/logo.png", - # references=[], + # # `references` must be non-empty; see _adaptive_card above. + # references=[ + # { + # "id": str(uuid4()), + # "type": "Web", + # "title": "Dragon Copilot adaptive card specification", + # "url": "https://learn.microsoft.com/en-us/industry/healthcare/dragon-copilot/extensions/adaptive-card-spec" + # } + # ], # ) \ No newline at end of file diff --git a/physician/src/samples/DragonCopilot/Workflow/pythonSampleExtension/app/tests/test_adaptive_card.py b/physician/src/samples/DragonCopilot/Workflow/pythonSampleExtension/app/tests/test_adaptive_card.py index 5e78d991..0c007297 100644 --- a/physician/src/samples/DragonCopilot/Workflow/pythonSampleExtension/app/tests/test_adaptive_card.py +++ b/physician/src/samples/DragonCopilot/Workflow/pythonSampleExtension/app/tests/test_adaptive_card.py @@ -38,7 +38,11 @@ def test_adaptive_card_structure(client): ac = card["adaptive_card_payload"] assert isinstance(ac, dict) assert ac.get("type") == "AdaptiveCard" - assert ac.get("version") == "1.6" + # Per the Dragon Copilot adaptive card spec, `version` is optional and is + # intentionally omitted by this sample. Setting it is allowed but not + # required; pin it here only if your extension requires a specific + # Adaptive Cards schema version. + assert "version" not in ac, "version should be omitted by default" # Validate actions exist actions = ac.get("actions") or [] From 828280329a19d3036dec89e01a1a3202d0c5799e Mon Sep 17 00:00:00 2001 From: brandonpollett Date: Thu, 11 Jun 2026 20:35:30 -0400 Subject: [PATCH 4/4] Address Copilot review comments: clarify v2 token semantics in TSG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related fixes to doc/Troubleshooting-Authentication.md, all stemming from the same root mistake: §2.7 had framed the JWT `aud` claim as being derived from `identifierUri`, which is only true for v1 tokens. With `requestedAccessTokenVersion: 2` (which this repo explicitly recommends), `aud` is the extension's Client ID GUID and `identifierUri` only appears as the *scope* the Runtime requests when acquiring a token. Changes: - §2.2 / matrix / pre-flight checklist: corrected the expected v2.0 issuer to `https://login.microsoftonline.com/{tenant-id}/v2.0` (added the missing `/v2.0` suffix that pairs with v2 tokens). - §2.7: rewrote the "Mental model" paragraph to state that `identifierUri` is the scope, and that a mismatch causes token acquisition to fail (symptom: no traffic — point at §2.4) rather than `InvalidAudience` on the extension side. - §2.7 "Quick validation procedure" now tells partners to verify `aud == Client ID GUID`, `azp == Runtime client ID`, and `iss == .../{tenant-id}/v2.0` — matching the sample extension's validator and the guidance in AuthenticationDesign.md. - §2.7: removed the "Validation rule for your extension code" block that recommended deriving expected `aud` from the HTTP Host header (which contradicted the v2-token guidance and would have encouraged unsafe host-header-trusting validation logic). - Matrix: split the old "InvalidAudience even though Client ID looks correct" row into two rows that correctly attribute (a) `aud` containing the App ID URI -> v1 tokens (points at §2.1) and (b) no-traffic / token-acquisition failure -> malformed `identifierUris` (points at §2.7). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/Troubleshooting-Authentication.md | 33 ++++++++++++++++----------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/doc/Troubleshooting-Authentication.md b/doc/Troubleshooting-Authentication.md index c0936c3a..840ef1f6 100644 --- a/doc/Troubleshooting-Authentication.md +++ b/doc/Troubleshooting-Authentication.md @@ -20,12 +20,13 @@ | Symptom | Most likely cause | Jump to | |---|---|---| | `401 Unauthorized` with `invalid_token` / `InvalidAudience` | `aud` claim does not match your extension's Client ID | [2.1](#21-401--invalidaudience) | -| `401 Unauthorized` with `InvalidIssuer` | `iss` claim does not match `https://login.microsoftonline.com/{your-tenant-id}/` | [2.2](#22-401--invalidissuer) | +| `401 Unauthorized` with `InvalidIssuer` | `iss` claim does not match `https://login.microsoftonline.com/{your-tenant-id}/v2.0` | [2.2](#22-401--invalidissuer) | | `401 Unauthorized` with `InvalidApplicationId` | `azp` claim is not the Dragon Copilot Runtime's client ID, or `idtyp` ≠ `app` | [2.3](#23-401--invalidapplicationid--idtyp--azp-checks) | | Extension is deployed but receives no traffic | Manifest endpoint/auth misconfigured, or a WAF / firewall / network ACL is dropping the Runtime's calls | [2.4](#24-no-traffic--silent-failure) | | `AADSTS500011: resource principal not found in tenant` | Dragon Copilot Runtime service principal is not provisioned in your tenant | [2.5](#25-aadsts500011-resource-principal-not-found) | | "Which JWT claim identifies the customer?" | Customer/tenant identity model question (multi-tenant routing) | [2.6](#26-which-claim-identifies-the-customer) | -| `InvalidAudience` even though Client ID looks correct | Application ID URI (`identifierUris`) host does not match the extension endpoint host | [2.7](#27-application-id-uri--identifieruris-mistakes) | +| `InvalidAudience` even though Client ID looks correct | App registration is on **v1 tokens** (set `requestedAccessTokenVersion: 2`) so `aud` carries the App ID URI instead of the Client ID GUID | [2.1](#21-401--invalidaudience) | +| No traffic / token-acquisition failure traced to scope | Application ID URI (`identifierUris`) is malformed or doesn't include the endpoint host | [2.7](#27-application-id-uri--identifieruris-mistakes) | --- @@ -50,7 +51,7 @@ Your extension validated the JWT signature, but the `aud` claim does not match w The `iss` claim does not match the tenant your extension trusts. **Checks:** -1. `iss` must be exactly `https://login.microsoftonline.com/{your-tenant-id}/` — note the trailing slash and v2.0 endpoint format. +1. `iss` must be exactly `https://login.microsoftonline.com/{your-tenant-id}/v2.0` (note the `/v2.0` suffix — this is the v2.0 issuer that pairs with `requestedAccessTokenVersion: 2`). 2. Confirm the tenant ID configured in your JWT validator matches the `auth.tenantId` in your `extension.yaml` manifest. The two values **must** match — the `dragon-copilot` CLI prompts for the same value to keep them aligned. 3. Do not use the `common` or `organizations` authority for a single-tenant extension. Pin the validator to your tenant GUID. @@ -119,9 +120,14 @@ If your scenario truly requires a customer-scoped JWT claim, raise it with your ### 2.7 Application ID URI / `identifierUris` mistakes -This is one of the most common sources of `InvalidAudience` failures after partners believe everything else is configured correctly. +A misconfigured Application ID URI on your Entra ID app registration is a common reason the Runtime cannot deliver requests to your extension. Symptoms range from "no traffic at all" to confusing token-acquisition errors in platform logs. -**Mental model:** the platform does not "send" an audience. It acquires a token using `identifierUri` **as the scope**, and the resulting `aud` claim is derived from it. If `identifierUri` does not line up with the actual endpoint host, the token your extension receives will have an `aud` that cannot match a sensible validation rule. +**Mental model:** the Dragon Copilot Extension Runtime acquires a token using `identifierUri` **as the scope it requests from Microsoft Entra ID**. Entra ID validates the requested scope against the `identifierUris` array on your app registration: + +- If the scope matches one of your app registration's `identifierUris`, Entra ID issues a token. With `requestedAccessTokenVersion: 2`, the resulting `aud` claim is your extension's **Client ID (GUID)** — *not* the `identifierUri` itself (see [§2.1](#21-401--invalidaudience)). +- If no `identifierUri` matches the requested scope, **token acquisition fails** and the Runtime never calls your endpoint. The symptom is "no traffic" (see [§2.4](#24-no-traffic--silent-failure)), not `InvalidAudience` on your side. + +`identifierUri` is therefore essential to *getting a token in the first place* — even though it does not appear in the `aud` claim of v2 tokens. **Canonical format:** @@ -146,8 +152,7 @@ api://{your-tenant-id}/{exact-endpoint-hostname} - Right: `api://{tenant}/api.mypartner.com` — **host only**, no path, port, or query. 4. **Re-using one hostname across environments.** - - The hostname **is** the environment discriminator (path is not part of `aud`). - - Use distinct hosts per environment: `dev.api.mypartner.com`, `staging.api.mypartner.com`, `api.mypartner.com`. + - Use distinct hostnames per environment (e.g. `dev.api.mypartner.com`, `staging.api.mypartner.com`, `api.mypartner.com`) so each environment can be granted/revoked independently and tokens can be traced back to the environment they were issued for. 5. **Forgetting that an app registration can hold multiple `identifierUris`.** - For non-prod consolidation, add multiple entries to the `identifierUris` array in the app registration manifest: @@ -157,11 +162,11 @@ api://{your-tenant-id}/{exact-endpoint-hostname} "api://{tenant}/staging.api.mypartner.com" ] ``` - - **For production, prefer a separate app registration** to prevent cross-environment token reuse. + - **For production, prefer a separate app registration** as defense-in-depth. 6. **Dev tunnel / temporary hostnames updated in the manifest but not in `identifierUris`.** - Common during local development with dev tunnels, ngrok, or VS Code Tunnels. - - When the tunnel host changes, update **both** the `endpoint` in `extension.yaml` *and* the `identifierUris` array. Otherwise the next inbound call fails with `InvalidAudience`. + - When the tunnel host changes, update **both** the `endpoint` in `extension.yaml` *and* the `identifierUris` array. Otherwise the Runtime can no longer acquire a token for your endpoint and traffic stops. 7. **Wildcard URIs (e.g. `api://{tenant}/*.mypartner.com`).** - Not supported. Enumerate each concrete host you need. @@ -172,11 +177,13 @@ api://{your-tenant-id}/{exact-endpoint-hostname} 1. Read the endpoint host from extension.yaml (tools[].endpoint). 2. Read the identifierUris array from your Entra app registration manifest. 3. Confirm the array contains exactly: api://{your-tenant-id}/{that-host}. -4. Decode a real token at https://jwt.ms and confirm aud equals that string. +4. Decode a real token at https://jwt.ms and confirm: + - aud == your extension's Client ID (GUID) (v2 tokens; see §2.1) + - azp == d9350f5d-71c2-46b9-b41d-3c5d51ffe6e8 (Runtime; see §2.3) + - iss == https://login.microsoftonline.com/{your-tenant-id}/v2.0 (see §2.2) ``` -**Validation rule for your extension code:** -Reject the request if `aud` does not equal `api://{your-tenant-id}/{Host header of the request}`. This prevents accidental cross-environment token replay. +If step 3 fails, the Runtime cannot get a token and you will see no traffic. If steps 1–3 pass but step 4 shows unexpected claims, jump to the relevant section above. --- @@ -224,7 +231,7 @@ Both the `endpoint` in `extension.yaml` **and** the `identifierUris` array on yo Work through this list before opening a support ticket. - [ ] Decoded a real failing token at and captured `iss`, `aud`, `azp`, `idtyp`, `exp`. -- [ ] Confirmed `iss` == `https://login.microsoftonline.com/{my-tenant-id}/`. +- [ ] Confirmed `iss` == `https://login.microsoftonline.com/{my-tenant-id}/v2.0`. - [ ] Confirmed `aud` == my extension's Client ID (GUID), and my app registration uses v2 tokens. - [ ] Confirmed `identifierUris` on the app registration includes `api://{my-tenant-id}/{endpoint-hostname}` matching the host in `extension.yaml`. - [ ] Confirmed `azp` == `d9350f5d-71c2-46b9-b41d-3c5d51ffe6e8` and `idtyp` == `app`.