diff --git a/docs/src/content/docs/reference/engines.md b/docs/src/content/docs/reference/engines.md index 51501b3fe53..bc24a5756e4 100644 --- a/docs/src/content/docs/reference/engines.md +++ b/docs/src/content/docs/reference/engines.md @@ -212,6 +212,39 @@ Set `COPILOT_PROVIDER_BASE_URL` in `engine.env` to activate BYOK mode. The crede | `COPILOT_PROVIDER_MAX_PROMPT_TOKENS` | Optional | Override the maximum prompt token limit (otherwise resolved from model catalog) | | `COPILOT_PROVIDER_MAX_OUTPUT_TOKENS` | Optional | Override the maximum output token limit | +#### OIDC authentication for BYOK (`engine.auth`) + +For providers that require short-lived OIDC tokens instead of static keys (for example Azure OpenAI with Entra-only auth), set `engine.auth.type: github-oidc`. + +This configuration is translated to AWF api-proxy auth settings at runtime: + +| `engine.auth` field | AWF setting | +|---|---| +| `type` | `AWF_AUTH_TYPE` | +| `audience` | `AWF_AUTH_AUDIENCE` | + +`type` currently supports only `github-oidc`. + +```yaml wrap +permissions: + id-token: write + +engine: + id: copilot + auth: + type: github-oidc + audience: https://cognitiveservices.azure.com + env: + COPILOT_PROVIDER_BASE_URL: https://my-resource.openai.azure.com + COPILOT_PROVIDER_TYPE: azure + COPILOT_MODEL: gpt-4.1 +``` + +`audience` is optional. When omitted, the provider-side default audience behavior applies. + +> [!IMPORTANT] +> OIDC BYOK requires `permissions: { id-token: write }` so GitHub Actions exposes `ACTIONS_ID_TOKEN_REQUEST_URL` and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` for token acquisition. + **Example: OpenAI-compatible provider** ```yaml wrap diff --git a/pkg/parser/schemas/main_workflow_schema.json b/pkg/parser/schemas/main_workflow_schema.json index b274e06ec34..9fbd50a2528 100644 --- a/pkg/parser/schemas/main_workflow_schema.json +++ b/pkg/parser/schemas/main_workflow_schema.json @@ -10106,6 +10106,10 @@ } }, "additionalProperties": false + }, + "auth": { + "$ref": "#/$defs/http_mcp_auth", + "description": "Authentication configuration for BYOK model-provider requests routed through the AWF api-proxy sidecar." } }, "required": ["id"], diff --git a/pkg/workflow/copilot_engine_execution.go b/pkg/workflow/copilot_engine_execution.go index 4d4762b8ecd..74ca199a861 100644 --- a/pkg/workflow/copilot_engine_execution.go +++ b/pkg/workflow/copilot_engine_execution.go @@ -427,6 +427,14 @@ touch %s if workflowData.EngineConfig != nil && len(workflowData.EngineConfig.Env) > 0 { maps.Copy(env, workflowData.EngineConfig.Env) } + if workflowData.EngineConfig != nil && workflowData.EngineConfig.Auth != nil { + if workflowData.EngineConfig.Auth.Type != "" { + env["AWF_AUTH_TYPE"] = workflowData.EngineConfig.Auth.Type + } + if workflowData.EngineConfig.Auth.Audience != "" { + env["AWF_AUTH_AUDIENCE"] = workflowData.EngineConfig.Auth.Audience + } + } // Add custom environment variables from agent config agentConfig := getAgentConfig(workflowData) diff --git a/pkg/workflow/copilot_engine_test.go b/pkg/workflow/copilot_engine_test.go index 344a02cf616..ffbb1470e36 100644 --- a/pkg/workflow/copilot_engine_test.go +++ b/pkg/workflow/copilot_engine_test.go @@ -12,6 +12,7 @@ import ( "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/testutil" + "github.com/github/gh-aw/pkg/types" ) func TestCopilotEngine(t *testing.T) { @@ -1775,6 +1776,33 @@ func TestCopilotEngineEnvOverridesTokenExpression(t *testing.T) { t.Errorf("Expected engine.env to add CUSTOM_VAR, got:\n%s", stepContent) } }) + + t.Run("engine auth maps to AWF auth environment variables", func(t *testing.T) { + workflowData := &WorkflowData{ + Name: "test-workflow", + EngineConfig: &EngineConfig{ + ID: "copilot", + Auth: &types.MCPAuthConfig{ + Type: "github-oidc", + Audience: "https://cognitiveservices.azure.com", + }, + }, + } + + steps := engine.GetExecutionSteps(workflowData, "/tmp/gh-aw/test.log") + if len(steps) != 1 { + t.Fatalf("Expected 1 step, got %d", len(steps)) + } + + stepContent := strings.Join([]string(steps[0]), "\n") + + if !strings.Contains(stepContent, "AWF_AUTH_TYPE: github-oidc") { + t.Errorf("Expected engine.auth.type to set AWF_AUTH_TYPE, got:\n%s", stepContent) + } + if !strings.Contains(stepContent, "AWF_AUTH_AUDIENCE: https://cognitiveservices.azure.com") { + t.Errorf("Expected engine.auth.audience to set AWF_AUTH_AUDIENCE, got:\n%s", stepContent) + } + }) } func TestCopilotEngineSetsDummyAPIKey(t *testing.T) { diff --git a/pkg/workflow/engine.go b/pkg/workflow/engine.go index 87e504c6481..e3fe947d1dd 100644 --- a/pkg/workflow/engine.go +++ b/pkg/workflow/engine.go @@ -25,6 +25,7 @@ type EngineConfig struct { Command string // Custom executable path (when set, skip installation steps) HarnessScript string // Custom Node.js harness script filename (replaces engine default harness script when supported) Env map[string]string + Auth *types.MCPAuthConfig // engine.auth (e.g., type: github-oidc, audience: ...) Config string Args []string Agent string // Agent identifier for copilot --agent flag (copilot engine only) @@ -261,6 +262,24 @@ func (c *Compiler) ExtractEngineConfig(frontmatter map[string]any) (string, *Eng } } + // Extract optional 'auth' field for BYOK provider auth configuration + if authVal, hasAuth := engineObj["auth"]; hasAuth { + if authObj, ok := authVal.(map[string]any); ok { + authConfig := &types.MCPAuthConfig{} + if authType, ok := authObj["type"].(string); ok { + authConfig.Type = authType + } + if audience, ok := authObj["audience"].(string); ok { + authConfig.Audience = audience + } + if authConfig.Type != "" { + config.Auth = authConfig + } + } else if authCfg, ok := authVal.(*types.MCPAuthConfig); ok { + config.Auth = authCfg + } + } + // Extract optional 'env' field (object/map of strings) if env, hasEnv := engineObj["env"]; hasEnv { if envMap, ok := env.(map[string]any); ok { diff --git a/pkg/workflow/engine_config_test.go b/pkg/workflow/engine_config_test.go index 5d0f1700138..37aa4c80feb 100644 --- a/pkg/workflow/engine_config_test.go +++ b/pkg/workflow/engine_config_test.go @@ -10,6 +10,7 @@ import ( "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/testutil" + "github.com/github/gh-aw/pkg/types" "github.com/stretchr/testify/assert" ) @@ -105,6 +106,26 @@ func TestExtractEngineConfig(t *testing.T) { expectedEngineSetting: "codex", expectedConfig: &EngineConfig{ID: "codex", Model: "gpt-4o"}, }, + { + name: "object format - with engine auth", + frontmatter: map[string]any{ + "engine": map[string]any{ + "id": "copilot", + "auth": map[string]any{ + "type": "github-oidc", + "audience": "https://cognitiveservices.azure.com", + }, + }, + }, + expectedEngineSetting: "copilot", + expectedConfig: &EngineConfig{ + ID: "copilot", + Auth: &types.MCPAuthConfig{ + Type: "github-oidc", + Audience: "https://cognitiveservices.azure.com", + }, + }, + }, { name: "object format - complete", frontmatter: map[string]any{ @@ -279,6 +300,23 @@ func TestExtractEngineConfig(t *testing.T) { t.Errorf("Expected config.HarnessScript '%s', got '%s'", test.expectedConfig.HarnessScript, config.HarnessScript) } + if test.expectedConfig.Auth == nil { + if config.Auth != nil { + t.Errorf("Expected config.Auth to be nil, got %+v", config.Auth) + } + } else { + if config.Auth == nil { + t.Errorf("Expected config.Auth %+v, got nil", test.expectedConfig.Auth) + } else { + if config.Auth.Type != test.expectedConfig.Auth.Type { + t.Errorf("Expected config.Auth.Type '%s', got '%s'", test.expectedConfig.Auth.Type, config.Auth.Type) + } + if config.Auth.Audience != test.expectedConfig.Auth.Audience { + t.Errorf("Expected config.Auth.Audience '%s', got '%s'", test.expectedConfig.Auth.Audience, config.Auth.Audience) + } + } + } + if len(config.Env) != len(test.expectedConfig.Env) { t.Errorf("Expected config.Env length %d, got %d", len(test.expectedConfig.Env), len(config.Env)) } else { @@ -324,6 +362,35 @@ This is a test workflow.`, expectedAI: "claude", expectedConfig: &EngineConfig{ID: "claude"}, }, + { + name: "object engine format - copilot with oidc auth", + content: `--- +on: push +permissions: + contents: read + issues: read + pull-requests: read + id-token: write +strict: false +engine: + id: copilot + auth: + type: github-oidc + audience: https://cognitiveservices.azure.com +--- + +# Test Workflow + +This is a test workflow.`, + expectedAI: "copilot", + expectedConfig: &EngineConfig{ + ID: "copilot", + Auth: &types.MCPAuthConfig{ + Type: "github-oidc", + Audience: "https://cognitiveservices.azure.com", + }, + }, + }, { name: "object engine format - complete", content: `--- @@ -407,6 +474,22 @@ This is a test workflow.`, if workflowData.EngineConfig.Model != test.expectedConfig.Model { t.Errorf("Expected EngineConfig.Model '%s', got '%s'", test.expectedConfig.Model, workflowData.EngineConfig.Model) } + if test.expectedConfig.Auth == nil { + if workflowData.EngineConfig.Auth != nil { + t.Errorf("Expected EngineConfig.Auth to be nil, got %+v", workflowData.EngineConfig.Auth) + } + } else { + if workflowData.EngineConfig.Auth == nil { + t.Errorf("Expected EngineConfig.Auth %+v, got nil", test.expectedConfig.Auth) + } else { + if workflowData.EngineConfig.Auth.Type != test.expectedConfig.Auth.Type { + t.Errorf("Expected EngineConfig.Auth.Type '%s', got '%s'", test.expectedConfig.Auth.Type, workflowData.EngineConfig.Auth.Type) + } + if workflowData.EngineConfig.Auth.Audience != test.expectedConfig.Auth.Audience { + t.Errorf("Expected EngineConfig.Auth.Audience '%s', got '%s'", test.expectedConfig.Auth.Audience, workflowData.EngineConfig.Auth.Audience) + } + } + } } }) }