From edbf9f832914926105e6f97d69979e364dbc52ba Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Tue, 30 Jun 2026 07:33:09 -0400 Subject: [PATCH] feat(auth): distinct auth_anthropic_exchange step (2-step OAuth, opt-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates the orchestrator's bespoke Anthropic oauth_exchange (deleted in agent PR5) into auth as a DISTINCT step — separate from the generic auth_oauth_exchange (google/facebook). Owns proto messages + codegen + 2-step handler (code->token at console.anthropic.com, token->API-key at api.anthropic.com; PKCE state-as-verifier quirk). Gated enable_anthropic_oauth default false (D17 confused-deputy: mints long-lived Anthropic API keys). Wired into the in-process EnginePlugin entry (PR1) for ratchet. gRPC path unchanged. Proto codegen command recorded. Co-Authored-By: Claude --- engine_plugin.go | 7 +- engine_plugin_test.go | 56 +++- go.mod | 2 +- internal/contracts/README.md | 29 ++ internal/contracts/auth.pb.go | 236 +++++++++++++++- internal/contracts/auth.proto | 31 +++ internal/plugin.go | 17 ++ internal/step_anthropic_exchange.go | 162 +++++++++++ internal/step_anthropic_exchange_test.go | 332 +++++++++++++++++++++++ plugin.contracts.json | 8 + 10 files changed, 861 insertions(+), 19 deletions(-) create mode 100644 internal/contracts/README.md create mode 100644 internal/step_anthropic_exchange.go create mode 100644 internal/step_anthropic_exchange_test.go diff --git a/engine_plugin.go b/engine_plugin.go index ed1858d..3f69563 100644 --- a/engine_plugin.go +++ b/engine_plugin.go @@ -37,7 +37,7 @@ func NewAuthEnginePlugin() plugin.EnginePlugin { Author: "GoCodeAlone", Description: "Passwordless authentication plugin: WebAuthn/passkeys, TOTP, email magic links", ModuleTypes: []string{"auth.credential"}, - StepTypes: []string{"step.auth_password_hash", "step.auth_password_verify"}, + StepTypes: []string{"step.auth_password_hash", "step.auth_password_verify", "step.auth_anthropic_exchange"}, }, }, } @@ -54,8 +54,9 @@ func (p *AuthEnginePlugin) StepFactories() map[string]plugin.StepFactory { panic("workflow-plugin-auth: sdk provider does not implement sdk.StepProvider") } return map[string]plugin.StepFactory{ - "step.auth_password_hash": wrapSDKStep(stepProvider, "step.auth_password_hash"), - "step.auth_password_verify": wrapSDKStep(stepProvider, "step.auth_password_verify"), + "step.auth_password_hash": wrapSDKStep(stepProvider, "step.auth_password_hash"), + "step.auth_password_verify": wrapSDKStep(stepProvider, "step.auth_password_verify"), + "step.auth_anthropic_exchange": wrapSDKStep(stepProvider, "step.auth_anthropic_exchange"), } } diff --git a/engine_plugin_test.go b/engine_plugin_test.go index b85b878..f4e333d 100644 --- a/engine_plugin_test.go +++ b/engine_plugin_test.go @@ -31,7 +31,7 @@ func TestAuthEnginePlugin_Shape(t *testing.T) { if factories == nil { t.Fatal("StepFactories() returned nil") } - for _, want := range []string{"step.auth_password_hash", "step.auth_password_verify"} { + for _, want := range []string{"step.auth_password_hash", "step.auth_password_verify", "step.auth_anthropic_exchange"} { if _, ok := factories[want]; !ok { t.Errorf("StepFactories() missing %q (have %d keys)", want, len(factories)) } @@ -74,3 +74,57 @@ func TestAuthEnginePlugin_ReverseBridgeDelegates(t *testing.T) { t.Fatalf("output hash = %q, want a bcrypt hash starting with $2", hash) } } + +// TestAuthEnginePlugin_AnthropicExchangeWired verifies the in-process opt-in +// step (D17) is served by NewAuthEnginePlugin via the reverse bridge, and that +// the D17 opt-in gate is honored in-process: with enable_anthropic_oauth false +// (default), the step returns a disabled result and performs NO exchange +// (proving the bridge delegates to anthropicExchangeStep.Execute, not a stub). +func TestAuthEnginePlugin_AnthropicExchangeWired(t *testing.T) { + p := NewAuthEnginePlugin() + factories := p.StepFactories() + factory, ok := factories["step.auth_anthropic_exchange"] + if !ok { + t.Fatalf("StepFactories() missing step.auth_anthropic_exchange (have %d keys: %v)", len(factories), mapKeys(factories)) + } + + // Opt-in OFF (default): step must short-circuit with a disabled result. + stepAny, err := factory("anthropic", nil /* config */, nil) + if err != nil { + t.Fatalf("factory returned error: %v", err) + } + pstep, ok := stepAny.(module.PipelineStep) + if !ok { + t.Fatalf("factory returned %T, does not implement module.PipelineStep", stepAny) + } + result, err := pstep.Execute(context.Background(), &module.PipelineContext{ + Current: map[string]any{ + "code": "code-123", + "redirect_uri": "https://app.example.com/callback", + "code_verifier": "verifier", + }, + }) + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + if result.Output["success"] != false { + t.Fatalf("opt-in OFF: expected success=false, got %#v", result.Output) + } + if result.Output["disabled"] != true { + t.Fatalf("opt-in OFF: expected disabled=true (D17 gate), got %#v", result.Output) + } + + // Opt-in ON with an empty config still produces a constructable step + // (the gate logic is in Execute, not construction). + if _, err := factory("anthropic-on", map[string]any{"enable_anthropic_oauth": true}, nil); err != nil { + t.Fatalf("opt-in ON factory returned error: %v", err) + } +} + +func mapKeys(m map[string]plugin.StepFactory) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} diff --git a/go.mod b/go.mod index 0b9ca3a..e1f5508 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/GoCodeAlone/workflow-plugin-auth go 1.26.4 require ( + github.com/GoCodeAlone/modular v1.13.4 github.com/GoCodeAlone/workflow v0.80.25 github.com/go-webauthn/webauthn v0.16.0 github.com/golang-jwt/jwt/v5 v5.3.1 @@ -17,7 +18,6 @@ require ( github.com/BurntSushi/toml v1.6.0 // indirect github.com/DataDog/datadog-go/v5 v5.8.3 // indirect github.com/GoCodeAlone/go-plugin v1.7.0 // indirect - github.com/GoCodeAlone/modular v1.13.4 // indirect github.com/GoCodeAlone/modular/modules/auth v1.17.0 // indirect github.com/GoCodeAlone/modular/modules/cache v1.17.0 // indirect github.com/GoCodeAlone/modular/modules/eventbus/v2 v2.10.0 // indirect diff --git a/internal/contracts/README.md b/internal/contracts/README.md new file mode 100644 index 0000000..3dc0475 --- /dev/null +++ b/internal/contracts/README.md @@ -0,0 +1,29 @@ +# contracts + +Generated protobuf Go bindings for `workflow-plugin-auth`. + +The `.proto.go` file in this directory (`auth.pb.go`) is generated from +`auth.proto`. There is no `buf` config and no `//go:generate` directive — the +command below must be run by hand when the proto changes. + +## Regenerate + +Requires (per the `auth.pb.go` header): + +- `protoc` v7.35.0 (libprotoc 35.0) +- `protoc-gen-go` v1.36.11 + +Run from the **repo root** (`workflow-plugin-auth/`): + +```sh +protoc --go_out=. --go_opt=paths=source_relative -I . internal/contracts/auth.proto +``` + +Notes: + +- `paths=source_relative` writes the output next to the source + (`internal/contracts/auth.pb.go`), matching the import path resolved by the + proto's own `option go_package = "github.com/GoCodeAlone/workflow-plugin-auth/internal/contracts"`. +- No `-M` remappings are needed — `go_package` is set in the proto. +- `google/protobuf/struct.proto` is resolved by the well-known types bundled + with `protoc` (no extra `-I` entry required). diff --git a/internal/contracts/auth.pb.go b/internal/contracts/auth.pb.go index 565222f..a161212 100644 --- a/internal/contracts/auth.pb.go +++ b/internal/contracts/auth.pb.go @@ -7371,6 +7371,199 @@ func (x *JWTIssueOutput) GetError() string { return "" } +// --- Anthropic 2-step OAuth exchange (step.auth_anthropic_exchange) --- +// DISTINCT from the generic auth_oauth_exchange (google/facebook). This step +// implements Anthropic's bespoke 2-step OAuth: code -> access_token at +// console.anthropic.com, then access_token -> permanent API key at +// api.anthropic.com. The PKCE `state` parameter is reused as the +// `code_verifier`. Gated by enable_anthropic_oauth (default false): the step +// mints long-lived Anthropic API keys, so only opt-in consumers (ratchet) +// enable it (D17 confused-deputy guard). +type AnthropicExchangeConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // enable_anthropic_oauth MUST be explicitly true for the step to perform the + // exchange. Default false: the step returns a disabled result and performs + // NO HTTP. Opt-in only. + EnableAnthropicOauth bool `protobuf:"varint,1,opt,name=enable_anthropic_oauth,json=enableAnthropicOauth,proto3" json:"enable_anthropic_oauth,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AnthropicExchangeConfig) Reset() { + *x = AnthropicExchangeConfig{} + mi := &file_internal_contracts_auth_proto_msgTypes[92] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AnthropicExchangeConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AnthropicExchangeConfig) ProtoMessage() {} + +func (x *AnthropicExchangeConfig) ProtoReflect() protoreflect.Message { + mi := &file_internal_contracts_auth_proto_msgTypes[92] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AnthropicExchangeConfig.ProtoReflect.Descriptor instead. +func (*AnthropicExchangeConfig) Descriptor() ([]byte, []int) { + return file_internal_contracts_auth_proto_rawDescGZIP(), []int{92} +} + +func (x *AnthropicExchangeConfig) GetEnableAnthropicOauth() bool { + if x != nil { + return x.EnableAnthropicOauth + } + return false +} + +type AnthropicExchangeInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` // authorization code from the SPA redirect + RedirectUri string `protobuf:"bytes,2,opt,name=redirect_uri,json=redirectUri,proto3" json:"redirect_uri,omitempty"` // runtime SPA redirect_uri (NOT config) + // code_verifier is the PKCE verifier. Anthropic reuses the OAuth `state` + // parameter as the code_verifier; either field is accepted as the verifier. + CodeVerifier string `protobuf:"bytes,3,opt,name=code_verifier,json=codeVerifier,proto3" json:"code_verifier,omitempty"` + State string `protobuf:"bytes,4,opt,name=state,proto3" json:"state,omitempty"` // alias for code_verifier (PKCE quirk) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AnthropicExchangeInput) Reset() { + *x = AnthropicExchangeInput{} + mi := &file_internal_contracts_auth_proto_msgTypes[93] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AnthropicExchangeInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AnthropicExchangeInput) ProtoMessage() {} + +func (x *AnthropicExchangeInput) ProtoReflect() protoreflect.Message { + mi := &file_internal_contracts_auth_proto_msgTypes[93] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AnthropicExchangeInput.ProtoReflect.Descriptor instead. +func (*AnthropicExchangeInput) Descriptor() ([]byte, []int) { + return file_internal_contracts_auth_proto_rawDescGZIP(), []int{93} +} + +func (x *AnthropicExchangeInput) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *AnthropicExchangeInput) GetRedirectUri() string { + if x != nil { + return x.RedirectUri + } + return "" +} + +func (x *AnthropicExchangeInput) GetCodeVerifier() string { + if x != nil { + return x.CodeVerifier + } + return "" +} + +func (x *AnthropicExchangeInput) GetState() string { + if x != nil { + return x.State + } + return "" +} + +type AnthropicExchangeOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Success bool `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"` + ApiKey string `protobuf:"bytes,2,opt,name=api_key,json=apiKey,proto3" json:"api_key,omitempty"` // permanent raw_key from create_api_key + AccessToken string `protobuf:"bytes,3,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` // ephemeral token from step 1 (debug only) + Error string `protobuf:"bytes,100,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AnthropicExchangeOutput) Reset() { + *x = AnthropicExchangeOutput{} + mi := &file_internal_contracts_auth_proto_msgTypes[94] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AnthropicExchangeOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AnthropicExchangeOutput) ProtoMessage() {} + +func (x *AnthropicExchangeOutput) ProtoReflect() protoreflect.Message { + mi := &file_internal_contracts_auth_proto_msgTypes[94] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AnthropicExchangeOutput.ProtoReflect.Descriptor instead. +func (*AnthropicExchangeOutput) Descriptor() ([]byte, []int) { + return file_internal_contracts_auth_proto_rawDescGZIP(), []int{94} +} + +func (x *AnthropicExchangeOutput) GetSuccess() bool { + if x != nil { + return x.Success + } + return false +} + +func (x *AnthropicExchangeOutput) GetApiKey() string { + if x != nil { + return x.ApiKey + } + return "" +} + +func (x *AnthropicExchangeOutput) GetAccessToken() string { + if x != nil { + return x.AccessToken + } + return "" +} + +func (x *AnthropicExchangeOutput) GetError() string { + if x != nil { + return x.Error + } + return "" +} + var File_internal_contracts_auth_proto protoreflect.FileDescriptor const file_internal_contracts_auth_proto_rawDesc = "" + @@ -8094,6 +8287,18 @@ const file_internal_contracts_auth_proto_rawDesc = "" + "\x05token\x18\x01 \x01(\tR\x05token\x12\x1d\n" + "\n" + "expires_at\x18\x02 \x01(\tR\texpiresAt\x12\x14\n" + + "\x05error\x18d \x01(\tR\x05error\"O\n" + + "\x17AnthropicExchangeConfig\x124\n" + + "\x16enable_anthropic_oauth\x18\x01 \x01(\bR\x14enableAnthropicOauth\"\x8a\x01\n" + + "\x16AnthropicExchangeInput\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\x12!\n" + + "\fredirect_uri\x18\x02 \x01(\tR\vredirectUri\x12#\n" + + "\rcode_verifier\x18\x03 \x01(\tR\fcodeVerifier\x12\x14\n" + + "\x05state\x18\x04 \x01(\tR\x05state\"\x85\x01\n" + + "\x17AnthropicExchangeOutput\x12\x18\n" + + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x17\n" + + "\aapi_key\x18\x02 \x01(\tR\x06apiKey\x12!\n" + + "\faccess_token\x18\x03 \x01(\tR\vaccessToken\x12\x14\n" + "\x05error\x18d \x01(\tR\x05errorB@Z>github.com/GoCodeAlone/workflow-plugin-auth/internal/contractsb\x06proto3" var ( @@ -8108,7 +8313,7 @@ func file_internal_contracts_auth_proto_rawDescGZIP() []byte { return file_internal_contracts_auth_proto_rawDescData } -var file_internal_contracts_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 92) +var file_internal_contracts_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 95) var file_internal_contracts_auth_proto_goTypes = []any{ (*CredentialModuleConfig)(nil), // 0: workflow.plugins.auth.v1.CredentialModuleConfig (*PasskeyStepConfig)(nil), // 1: workflow.plugins.auth.v1.PasskeyStepConfig @@ -8202,7 +8407,10 @@ var file_internal_contracts_auth_proto_goTypes = []any{ (*JWTIssueConfig)(nil), // 89: workflow.plugins.auth.v1.JWTIssueConfig (*JWTIssueInput)(nil), // 90: workflow.plugins.auth.v1.JWTIssueInput (*JWTIssueOutput)(nil), // 91: workflow.plugins.auth.v1.JWTIssueOutput - (*structpb.Struct)(nil), // 92: google.protobuf.Struct + (*AnthropicExchangeConfig)(nil), // 92: workflow.plugins.auth.v1.AnthropicExchangeConfig + (*AnthropicExchangeInput)(nil), // 93: workflow.plugins.auth.v1.AnthropicExchangeInput + (*AnthropicExchangeOutput)(nil), // 94: workflow.plugins.auth.v1.AnthropicExchangeOutput + (*structpb.Struct)(nil), // 95: google.protobuf.Struct } var file_internal_contracts_auth_proto_depIdxs = []int32{ 77, // 0: workflow.plugins.auth.v1.AuthAdminConfig.providers:type_name -> workflow.plugins.auth.v1.AuthProviderDescriptor @@ -8211,27 +8419,27 @@ var file_internal_contracts_auth_proto_depIdxs = []int32{ 45, // 3: workflow.plugins.auth.v1.AuthAdminControl.options:type_name -> workflow.plugins.auth.v1.AuthAdminControlOption 46, // 4: workflow.plugins.auth.v1.AuthAdminControlGroup.controls:type_name -> workflow.plugins.auth.v1.AuthAdminControl 51, // 5: workflow.plugins.auth.v1.AuthAdminContributionConfig.permissions:type_name -> workflow.plugins.auth.v1.AuthAdminContributionPermission - 92, // 6: workflow.plugins.auth.v1.AuthAdminContributionConfig.metadata:type_name -> google.protobuf.Struct + 95, // 6: workflow.plugins.auth.v1.AuthAdminContributionConfig.metadata:type_name -> google.protobuf.Struct 52, // 7: workflow.plugins.auth.v1.AuthAdminContributionInput.contribution:type_name -> workflow.plugins.auth.v1.AuthAdminContribution 51, // 8: workflow.plugins.auth.v1.AuthAdminContribution.permissions:type_name -> workflow.plugins.auth.v1.AuthAdminContributionPermission - 92, // 9: workflow.plugins.auth.v1.AuthAdminContribution.metadata:type_name -> google.protobuf.Struct + 95, // 9: workflow.plugins.auth.v1.AuthAdminContribution.metadata:type_name -> google.protobuf.Struct 52, // 10: workflow.plugins.auth.v1.AuthAdminContributionOutput.contribution:type_name -> workflow.plugins.auth.v1.AuthAdminContribution 51, // 11: workflow.plugins.auth.v1.AuthAdminIdentityContributionConfig.permissions:type_name -> workflow.plugins.auth.v1.AuthAdminContributionPermission - 92, // 12: workflow.plugins.auth.v1.AuthAdminIdentityContributionConfig.metadata:type_name -> google.protobuf.Struct + 95, // 12: workflow.plugins.auth.v1.AuthAdminIdentityContributionConfig.metadata:type_name -> google.protobuf.Struct 52, // 13: workflow.plugins.auth.v1.AuthAdminIdentityDescribeInput.contribution:type_name -> workflow.plugins.auth.v1.AuthAdminContribution 52, // 14: workflow.plugins.auth.v1.AuthAdminIdentityDescribeOutput.contribution:type_name -> workflow.plugins.auth.v1.AuthAdminContribution 47, // 15: workflow.plugins.auth.v1.AuthAdminDescribeOutput.groups:type_name -> workflow.plugins.auth.v1.AuthAdminControlGroup - 92, // 16: workflow.plugins.auth.v1.AuthAdminDescribeOutput.effective_config:type_name -> google.protobuf.Struct - 92, // 17: workflow.plugins.auth.v1.AuthAdminDescribeOutput.methods_policy:type_name -> google.protobuf.Struct + 95, // 16: workflow.plugins.auth.v1.AuthAdminDescribeOutput.effective_config:type_name -> google.protobuf.Struct + 95, // 17: workflow.plugins.auth.v1.AuthAdminDescribeOutput.methods_policy:type_name -> google.protobuf.Struct 48, // 18: workflow.plugins.auth.v1.AuthAdminDescribeOutput.warnings:type_name -> workflow.plugins.auth.v1.AuthAdminDiagnostic - 92, // 19: workflow.plugins.auth.v1.AuthAdminValidateInput.desired_config:type_name -> google.protobuf.Struct + 95, // 19: workflow.plugins.auth.v1.AuthAdminValidateInput.desired_config:type_name -> google.protobuf.Struct 77, // 20: workflow.plugins.auth.v1.AuthAdminValidateInput.providers:type_name -> workflow.plugins.auth.v1.AuthProviderDescriptor - 92, // 21: workflow.plugins.auth.v1.AuthAdminValidateOutput.accepted_config:type_name -> google.protobuf.Struct - 92, // 22: workflow.plugins.auth.v1.AuthAdminValidateOutput.methods_policy:type_name -> google.protobuf.Struct + 95, // 21: workflow.plugins.auth.v1.AuthAdminValidateOutput.accepted_config:type_name -> google.protobuf.Struct + 95, // 22: workflow.plugins.auth.v1.AuthAdminValidateOutput.methods_policy:type_name -> google.protobuf.Struct 48, // 23: workflow.plugins.auth.v1.AuthAdminValidateOutput.errors:type_name -> workflow.plugins.auth.v1.AuthAdminDiagnostic 48, // 24: workflow.plugins.auth.v1.AuthAdminValidateOutput.warnings:type_name -> workflow.plugins.auth.v1.AuthAdminDiagnostic - 92, // 25: workflow.plugins.auth.v1.OAuthExchangeOutput.raw_tokens:type_name -> google.protobuf.Struct - 92, // 26: workflow.plugins.auth.v1.OAuthUserinfoOutput.raw_claims:type_name -> google.protobuf.Struct + 95, // 25: workflow.plugins.auth.v1.OAuthExchangeOutput.raw_tokens:type_name -> google.protobuf.Struct + 95, // 26: workflow.plugins.auth.v1.OAuthUserinfoOutput.raw_claims:type_name -> google.protobuf.Struct 74, // 27: workflow.plugins.auth.v1.AuthProviderConfigField.options:type_name -> workflow.plugins.auth.v1.AuthProviderConfigOption 75, // 28: workflow.plugins.auth.v1.AuthProviderCapability.config_fields:type_name -> workflow.plugins.auth.v1.AuthProviderConfigField 76, // 29: workflow.plugins.auth.v1.AuthProviderDescriptor.capabilities:type_name -> workflow.plugins.auth.v1.AuthProviderCapability @@ -8240,7 +8448,7 @@ var file_internal_contracts_auth_proto_depIdxs = []int32{ 77, // 32: workflow.plugins.auth.v1.AuthProviderCatalogOutput.providers:type_name -> workflow.plugins.auth.v1.AuthProviderDescriptor 48, // 33: workflow.plugins.auth.v1.AuthProviderCatalogOutput.warnings:type_name -> workflow.plugins.auth.v1.AuthAdminDiagnostic 82, // 34: workflow.plugins.auth.v1.CredentialListOutput.credentials:type_name -> workflow.plugins.auth.v1.CredentialSummary - 92, // 35: workflow.plugins.auth.v1.JWTIssueInput.claims:type_name -> google.protobuf.Struct + 95, // 35: workflow.plugins.auth.v1.JWTIssueInput.claims:type_name -> google.protobuf.Struct 36, // [36:36] is the sub-list for method output_type 36, // [36:36] is the sub-list for method input_type 36, // [36:36] is the sub-list for extension type_name @@ -8269,7 +8477,7 @@ func file_internal_contracts_auth_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_internal_contracts_auth_proto_rawDesc), len(file_internal_contracts_auth_proto_rawDesc)), NumEnums: 0, - NumMessages: 92, + NumMessages: 95, NumExtensions: 0, NumServices: 0, }, diff --git a/internal/contracts/auth.proto b/internal/contracts/auth.proto index 29fecce..94bd19d 100644 --- a/internal/contracts/auth.proto +++ b/internal/contracts/auth.proto @@ -792,3 +792,34 @@ message JWTIssueOutput { string expires_at = 2; // RFC3339 string error = 100; } + +// --- Anthropic 2-step OAuth exchange (step.auth_anthropic_exchange) --- +// DISTINCT from the generic auth_oauth_exchange (google/facebook). This step +// implements Anthropic's bespoke 2-step OAuth: code -> access_token at +// console.anthropic.com, then access_token -> permanent API key at +// api.anthropic.com. The PKCE `state` parameter is reused as the +// `code_verifier`. Gated by enable_anthropic_oauth (default false): the step +// mints long-lived Anthropic API keys, so only opt-in consumers (ratchet) +// enable it (D17 confused-deputy guard). +message AnthropicExchangeConfig { + // enable_anthropic_oauth MUST be explicitly true for the step to perform the + // exchange. Default false: the step returns a disabled result and performs + // NO HTTP. Opt-in only. + bool enable_anthropic_oauth = 1; +} + +message AnthropicExchangeInput { + string code = 1; // authorization code from the SPA redirect + string redirect_uri = 2; // runtime SPA redirect_uri (NOT config) + // code_verifier is the PKCE verifier. Anthropic reuses the OAuth `state` + // parameter as the code_verifier; either field is accepted as the verifier. + string code_verifier = 3; + string state = 4; // alias for code_verifier (PKCE quirk) +} + +message AnthropicExchangeOutput { + bool success = 1; + string api_key = 2; // permanent raw_key from create_api_key + string access_token = 3; // ephemeral token from step 1 (debug only) + string error = 100; +} diff --git a/internal/plugin.go b/internal/plugin.go index 0cc0f61..129fe6b 100644 --- a/internal/plugin.go +++ b/internal/plugin.go @@ -52,6 +52,13 @@ var allStepTypes = []string{ "step.auth_credential_revoke", "step.auth_bootstrap_redeem", "step.auth_jwt_issue", + // NOTE: step.auth_anthropic_exchange is intentionally NOT listed here. + // allStepTypes backs GetManifest()/StepTypes() — the gRPC-served surface + // that plugin.json capabilities must mirror (TestIntegration_PluginMani- + // festAndStepTypes enforces parity). The anthropic step is in-process- + // only (D17): it is registered in the CreateStep switch below so the + // reverse sdk->in-process bridge (engine_plugin.go) can resolve it, but + // it is NOT served over gRPC and MUST NOT appear in plugin.json. } type authPlugin struct{} @@ -180,6 +187,11 @@ func (p *authPlugin) CreateStep(typeName, name string, config map[string]any) (s return newBootstrapRedeemStep(name, config), nil case "step.auth_jwt_issue": return newJWTIssueStep(name, config), nil + case "step.auth_anthropic_exchange": + // In-process opt-in step (D17). Registered here so the reverse + // sdk->in-process bridge in engine_plugin.go can resolve it via + // CreateStep, mirroring the password steps. + return newAnthropicExchangeStep(name, config), nil default: return nil, fmt.Errorf("unknown step type: %s", typeName) } @@ -372,6 +384,11 @@ var authContractRegistry = &pb.ContractRegistry{ stepContract("step.auth_credential_revoke", "EmptyConfig", "CredentialRevokeInput", "CredentialRevokeOutput"), stepContract("step.auth_bootstrap_redeem", "BootstrapRedeemConfig", "BootstrapRedeemInput", "BootstrapRedeemOutput"), stepContract("step.auth_jwt_issue", "JWTIssueConfig", "JWTIssueInput", "JWTIssueOutput"), + // In-process-only (D17): declared in the contract registry so strict- + // proto validation accepts the descriptor for in-process consumers + // (ratchet), but NOT in allStepTypes/plugin.json (gRPC GetManifest) — + // the step is served via NewAuthEnginePlugin, not the gRPC binary. + stepContract("step.auth_anthropic_exchange", "AnthropicExchangeConfig", "AnthropicExchangeInput", "AnthropicExchangeOutput"), }, } diff --git a/internal/step_anthropic_exchange.go b/internal/step_anthropic_exchange.go new file mode 100644 index 0000000..412cdca --- /dev/null +++ b/internal/step_anthropic_exchange.go @@ -0,0 +1,162 @@ +package internal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + sdk "github.com/GoCodeAlone/workflow/plugin/external/sdk" +) + +// Anthropic OAuth constants (recovered from the orchestrator's deleted +// step_oauth_exchange.go — agent repo commit 6b94458^). The client_id is the +// public Claude CLI identifier; it is NOT a secret and is hardcoded to match +// the CLI's registered redirect. +const ( + anthropicOAuthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" + anthropicOAuthTokenURL = "https://console.anthropic.com/v1/oauth/token" + anthropicCreateAPIKeyURL = "https://api.anthropic.com/api/oauth/claude_cli/create_api_key" + anthropicHTTPTimeout = 15 * time.Second +) + +// anthropicExchangeStep implements step.auth_anthropic_exchange — Anthropic's +// bespoke 2-step OAuth, DISTINCT from the generic auth_oauth_exchange +// (google/facebook): +// 1. POST console.anthropic.com/v1/oauth/token (code -> access_token) +// 2. POST api.anthropic.com/.../create_api_key (access_token -> permanent key) +// +// The PKCE `state` parameter is reused as the `code_verifier` (Anthropic +// quirk). The redirect_uri is read from RUNTIME input (the SPA's redirect), +// never from config. +// +// D17 confused-deputy gate: enable_anthropic_oauth MUST be explicitly true in +// the step config for the exchange to proceed. The default (false) returns a +// disabled result and performs NO HTTP — the step mints long-lived Anthropic +// API keys, so only opt-in consumers (ratchet) enable it. +type anthropicExchangeStep struct { + name string + config map[string]any + client *http.Client +} + +func newAnthropicExchangeStep(name string, config map[string]any) *anthropicExchangeStep { + return &anthropicExchangeStep{name: name, config: config, client: &http.Client{Timeout: anthropicHTTPTimeout}} +} + +func (s *anthropicExchangeStep) Execute(ctx context.Context, _ map[string]any, _ map[string]map[string]any, current, _, _ map[string]any) (*sdk.StepResult, error) { + // D17 opt-in gate: default-off. The step mints long-lived Anthropic API + // keys; only opt-in consumers enable it. + if !oauthStrictBool(s.config, "enable_anthropic_oauth") { + return &sdk.StepResult{Output: map[string]any{ + "success": false, + "disabled": true, + "error": "auth_anthropic_exchange: disabled (enable_anthropic_oauth not set true); this step mints long-lived Anthropic API keys and is opt-in only", + }}, nil + } + + code := oauthString(current, "code") + if code == "" { + return &sdk.StepResult{Output: map[string]any{ + "success": false, + "error": "missing code", + }}, nil + } + redirectURI := oauthString(current, "redirect_uri") + // PKCE quirk: code_verifier is the verifier; `state` is an alias used by + // the CLI redirect. Either field satisfies the verifier, and the OAuth + // `state` param is set to the SAME value (Anthropic reuses state as the + // code_verifier). + codeVerifier := oauthString(current, "code_verifier") + if codeVerifier == "" { + codeVerifier = oauthString(current, "state") + } + + tokenURL, apiKeyURL := s.endpoints() + + // Step 1: exchange code for access token (JSON body, per Anthropic — NOT + // form-encoded like the generic oauth providers). + tokenReqBody, err := json.Marshal(map[string]any{ + "code": code, + "state": codeVerifier, + "grant_type": "authorization_code", + "client_id": anthropicOAuthClientID, + "redirect_uri": redirectURI, + "code_verifier": codeVerifier, + }) + if err != nil { + return nil, fmt.Errorf("auth_anthropic_exchange: marshal token request: %w", err) + } + tokenReq, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, bytes.NewReader(tokenReqBody)) + if err != nil { + return nil, fmt.Errorf("auth_anthropic_exchange: create token request: %w", err) + } + tokenReq.Header.Set("Content-Type", "application/json") + tokenReq.Header.Set("Accept", "application/json") + + var tokenResult map[string]any + if err := oauthDoJSON(s.client, tokenReq, "anthropic token endpoint", &tokenResult); err != nil { + return &sdk.StepResult{Output: map[string]any{ + "success": false, + "error": err.Error(), + }}, nil + } + accessToken := oauthClaimString(tokenResult, "access_token") + if accessToken == "" { + return &sdk.StepResult{Output: map[string]any{ + "success": false, + "error": "no access_token in token response", + }}, nil + } + + // Step 2: create a permanent API key using the access token. + apiKeyReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiKeyURL, http.NoBody) + if err != nil { + return nil, fmt.Errorf("auth_anthropic_exchange: create api_key request: %w", err) + } + apiKeyReq.Header.Set("Authorization", "Bearer "+accessToken) + apiKeyReq.Header.Set("Content-Type", "application/json") + apiKeyReq.Header.Set("Accept", "application/json") + + var apiKeyResult map[string]any + if err := oauthDoJSON(s.client, apiKeyReq, "anthropic api_key endpoint", &apiKeyResult); err != nil { + return &sdk.StepResult{Output: map[string]any{ + "success": false, + "error": err.Error(), + }}, nil + } + rawKey := oauthClaimString(apiKeyResult, "raw_key") + if rawKey == "" { + return &sdk.StepResult{Output: map[string]any{ + "success": false, + "error": "no raw_key in api_key response", + }}, nil + } + + return &sdk.StepResult{Output: map[string]any{ + "success": true, + "api_key": rawKey, + "access_token": accessToken, + }}, nil +} + +// endpoints returns the token + api_key URLs. Production constants are used +// unless the host sets BOTH the override URL AND the strict-bool +// allow_insecure_test_oauth_endpoints flag (mirrors the generic oauth step's +// convention: a YAML-coerced string "true" is rejected so a config typo cannot +// silently redirect key-minting traffic). +func (s *anthropicExchangeStep) endpoints() (tokenURL, apiKeyURL string) { + tokenURL = anthropicOAuthTokenURL + apiKeyURL = anthropicCreateAPIKeyURL + if oauthStrictBool(s.config, "allow_insecure_test_oauth_endpoints") { + if override := oauthString(s.config, "anthropic_oauth_token_url"); override != "" { + tokenURL = override + } + if override := oauthString(s.config, "anthropic_create_api_key_url"); override != "" { + apiKeyURL = override + } + } + return tokenURL, apiKeyURL +} diff --git a/internal/step_anthropic_exchange_test.go b/internal/step_anthropic_exchange_test.go new file mode 100644 index 0000000..1e7974f --- /dev/null +++ b/internal/step_anthropic_exchange_test.go @@ -0,0 +1,332 @@ +package internal + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +// TestAnthropicExchange_OptInOffReturnsDisabled verifies the D17 confused-deputy +// gate: when enable_anthropic_oauth is false (the default), the step MUST refuse +// to perform the exchange and MUST NOT issue any HTTP request — the step mints +// long-lived Anthropic API keys, so it is opt-in only (ratchet enables it). +func TestAnthropicExchange_OptInOffReturnsDisabled(t *testing.T) { + // Sentinel server: if hit, the gate failed and HTTP was issued. + hit := false + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + hit = true + })) + defer srv.Close() + + step := newAnthropicExchangeStep("test", map[string]any{ + // enable_anthropic_oauth intentionally OMITTED (default false). + "anthropic_oauth_token_url": srv.URL, + "allow_insecure_test_oauth_endpoints": true, + }) + + result, err := step.Execute(context.Background(), nil, nil, map[string]any{ + "code": "code-123", + "redirect_uri": "https://app.example.com/callback", + "code_verifier": "verifier-123", + }, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hit { + t.Fatalf("opt-in OFF: step issued HTTP request (D17 gate failed)") + } + if result == nil || result.Output["success"] != false { + t.Fatalf("expected success=false, got %#v", result) + } + if result.Output["disabled"] != true { + t.Fatalf("expected disabled=true, got %#v", result) + } + if k, present := result.Output["api_key"]; present && k != "" { + t.Fatalf("expected no api_key when disabled, got %v", result.Output["api_key"]) + } +} + +// TestAnthropicExchange_OptInOnStringFlagRejected mirrors the existing oauth +// step convention: allow_insecure_test_oauth_endpoints must be a strict bool, +// not a string, so a YAML coercion cannot silently enable test overrides. +func TestAnthropicExchange_OptInOnStringFlagRejected(t *testing.T) { + step := newAnthropicExchangeStep("test", map[string]any{ + "enable_anthropic_oauth": true, + "allow_insecure_test_oauth_endpoints": "true", // string — must be rejected + }) + + result, err := step.Execute(context.Background(), nil, nil, map[string]any{ + "code": "code-123", + "redirect_uri": "https://app.example.com/callback", + }, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Endpoint override rejected -> production constants retained -> step hits + // the real network and fails (we cannot assert on the real endpoint, but + // success MUST be false either via disabled-on-error or network failure). + if result.Output["success"] == true { + t.Fatalf("expected success=false with string test flag, got %#v", result.Output) + } +} + +// TestAnthropicExchange_TwoStepReturnsAPIKey verifies the full 2-step exchange +// against stub servers mocking both Anthropic endpoints: +// 1. POST token URL (code -> access_token) +// 2. POST create_api_key URL (access_token -> permanent raw_key) +// +// It asserts the PKCE quirk (state == code_verifier) and that the redirect_uri +// comes from RUNTIME input, not config. +func TestAnthropicExchange_TwoStepReturnsAPIKey(t *testing.T) { + var ( + tokenBody url.Values + apiKeyAuthz string + apiKeyCalled bool + ) + + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("token: expected POST, got %s", r.Method) + } + body, _ := io.ReadAll(r.Body) + // Token endpoint expects a JSON body (per the recovered orchestrator + // source), not form-encoded like the generic oauth providers. + var raw map[string]any + _ = json.Unmarshal(body, &raw) + form := url.Values{} + for k, v := range raw { + if s, ok := v.(string); ok { + form.Set(k, s) + } + } + tokenBody = form + _ = json.NewEncoder(w).Encode(map[string]any{ + "access_token": "access-xyz", + "token_type": "Bearer", + }) + })) + defer tokenSrv.Close() + + apiKeySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiKeyCalled = true + if r.Method != http.MethodPost { + t.Fatalf("api_key: expected POST, got %s", r.Method) + } + apiKeyAuthz = r.Header.Get("Authorization") + _ = json.NewEncoder(w).Encode(map[string]any{ + "raw_key": "sk-ant-permanent-key", + }) + })) + defer apiKeySrv.Close() + + step := newAnthropicExchangeStep("test", map[string]any{ + "enable_anthropic_oauth": true, + "anthropic_oauth_token_url": tokenSrv.URL, + "anthropic_create_api_key_url": apiKeySrv.URL, + "allow_insecure_test_oauth_endpoints": true, + }) + + result, err := step.Execute(context.Background(), nil, nil, map[string]any{ + "code": "code-123", + "redirect_uri": "https://spa.example.com/auth/callback", + "code_verifier": "verifier-456", + }, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !apiKeyCalled { + t.Fatalf("expected step 2 (create_api_key) to be called") + } + if result.Output["success"] != true { + t.Fatalf("expected success=true, got %#v", result.Output) + } + if result.Output["api_key"] != "sk-ant-permanent-key" { + t.Fatalf("expected api_key=sk-ant-permanent-key, got %v", result.Output["api_key"]) + } + if result.Output["access_token"] != "access-xyz" { + t.Fatalf("expected access_token=access-xyz, got %v", result.Output["access_token"]) + } + + // --- Step 1 request assertions --- + if tokenBody.Get("grant_type") != "authorization_code" { + t.Fatalf("expected grant_type=authorization_code, got %q", tokenBody.Get("grant_type")) + } + if tokenBody.Get("code") != "code-123" { + t.Fatalf("expected code in token request, got %q", tokenBody.Get("code")) + } + if tokenBody.Get("redirect_uri") != "https://spa.example.com/auth/callback" { + t.Fatalf("expected runtime redirect_uri in token request, got %q", tokenBody.Get("redirect_uri")) + } + // Hardcoded public Claude CLI client id. + if tokenBody.Get("client_id") != "9d1c250a-e61b-44d9-88ed-5944d1962f5e" { + t.Fatalf("expected hardcoded Claude CLI client_id, got %q", tokenBody.Get("client_id")) + } + // PKCE quirk: state == code_verifier. + if tokenBody.Get("code_verifier") != "verifier-456" { + t.Fatalf("expected code_verifier=verifier-456, got %q", tokenBody.Get("code_verifier")) + } + if tokenBody.Get("state") != "verifier-456" { + t.Fatalf("PKCE quirk: expected state=verifier-456 (reused as code_verifier), got %q", tokenBody.Get("state")) + } + + // --- Step 2 request assertions --- + if apiKeyAuthz != "Bearer access-xyz" { + t.Fatalf("expected step 2 Authorization=Bearer access-xyz, got %q", apiKeyAuthz) + } +} + +// TestAnthropicExchange_StateAsVerifierAlias verifies the PKCE quirk from the +// other direction: when only `state` is supplied (no explicit code_verifier), +// the step uses state as the verifier for both code_verifier and state params. +func TestAnthropicExchange_StateAsVerifierAlias(t *testing.T) { + var tokenBody url.Values + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var raw map[string]any + _ = json.Unmarshal(body, &raw) + tokenBody = url.Values{} + for k, v := range raw { + if s, ok := v.(string); ok { + tokenBody.Set(k, s) + } + } + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "access-xyz"}) + })) + defer tokenSrv.Close() + + apiKeySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"raw_key": "sk-ant-key"}) + })) + defer apiKeySrv.Close() + + step := newAnthropicExchangeStep("test", map[string]any{ + "enable_anthropic_oauth": true, + "anthropic_oauth_token_url": tokenSrv.URL, + "anthropic_create_api_key_url": apiKeySrv.URL, + "allow_insecure_test_oauth_endpoints": true, + }) + + _, err := step.Execute(context.Background(), nil, nil, map[string]any{ + "code": "code-123", + "redirect_uri": "https://spa.example.com/auth/callback", + "state": "state-only-verifier", // no code_verifier field + }, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tokenBody.Get("code_verifier") != "state-only-verifier" { + t.Fatalf("expected state to be used as code_verifier, got %q", tokenBody.Get("code_verifier")) + } + if tokenBody.Get("state") != "state-only-verifier" { + t.Fatalf("expected state echoed, got %q", tokenBody.Get("state")) + } +} + +// TestAnthropicExchange_MissingCodeReturnsError verifies input validation: a +// missing authorization code short-circuits before any HTTP. +func TestAnthropicExchange_MissingCodeReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Fatalf("expected no HTTP when code missing") + })) + defer srv.Close() + + step := newAnthropicExchangeStep("test", map[string]any{ + "enable_anthropic_oauth": true, + "anthropic_oauth_token_url": srv.URL, + "anthropic_create_api_key_url": srv.URL, + "allow_insecure_test_oauth_endpoints": true, + }) + + result, err := step.Execute(context.Background(), nil, nil, map[string]any{ + "redirect_uri": "https://app.example.com/callback", + "code_verifier": "verifier", + }, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Output["success"] != false { + t.Fatalf("expected success=false for missing code, got %#v", result.Output) + } +} + +// TestAnthropicExchange_TokenEndpointErrorPropagates verifies that a non-2xx +// from step 1 aborts the exchange (step 2 is never reached). +func TestAnthropicExchange_TokenEndpointErrorPropagates(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid_grant"}`)) + })) + defer tokenSrv.Close() + + apiKeyCalled := false + apiKeySrv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + apiKeyCalled = true + })) + defer apiKeySrv.Close() + + step := newAnthropicExchangeStep("test", map[string]any{ + "enable_anthropic_oauth": true, + "anthropic_oauth_token_url": tokenSrv.URL, + "anthropic_create_api_key_url": apiKeySrv.URL, + "allow_insecure_test_oauth_endpoints": true, + }) + + result, err := step.Execute(context.Background(), nil, nil, map[string]any{ + "code": "bad-code", + "redirect_uri": "https://app.example.com/callback", + }, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if apiKeyCalled { + t.Fatalf("step 2 must NOT be reached when step 1 fails") + } + if result.Output["success"] != false { + t.Fatalf("expected success=false on token error, got %#v", result.Output) + } + if k, present := result.Output["api_key"]; present && k != "" { + t.Fatalf("expected no api_key on token error, got %v", result.Output["api_key"]) + } +} + +// TestAnthropicExchange_NoRawKeyReturnsError verifies step 2 response parsing: +// a successful 2xx without raw_key is treated as a failure (no silent empty +// api_key). +func TestAnthropicExchange_NoRawKeyReturnsError(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "access-xyz"}) + })) + defer tokenSrv.Close() + + apiKeySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"id": "k_123"}) // no raw_key + })) + defer apiKeySrv.Close() + + step := newAnthropicExchangeStep("test", map[string]any{ + "enable_anthropic_oauth": true, + "anthropic_oauth_token_url": tokenSrv.URL, + "anthropic_create_api_key_url": apiKeySrv.URL, + "allow_insecure_test_oauth_endpoints": true, + }) + + result, err := step.Execute(context.Background(), nil, nil, map[string]any{ + "code": "code-123", + "redirect_uri": "https://app.example.com/callback", + }, nil, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Output["success"] != false { + t.Fatalf("expected success=false when raw_key missing, got %#v", result.Output) + } + if k, present := result.Output["api_key"]; present && k != "" { + t.Fatalf("expected no api_key when raw_key missing, got %v", result.Output["api_key"]) + } +} diff --git a/plugin.contracts.json b/plugin.contracts.json index 7368f1e..b780cb6 100644 --- a/plugin.contracts.json +++ b/plugin.contracts.json @@ -286,6 +286,14 @@ "config": "workflow.plugins.auth.v1.JWTIssueConfig", "input": "workflow.plugins.auth.v1.JWTIssueInput", "output": "workflow.plugins.auth.v1.JWTIssueOutput" + }, + { + "kind": "step", + "type": "step.auth_anthropic_exchange", + "mode": "strict", + "config": "workflow.plugins.auth.v1.AnthropicExchangeConfig", + "input": "workflow.plugins.auth.v1.AnthropicExchangeInput", + "output": "workflow.plugins.auth.v1.AnthropicExchangeOutput" } ] }