diff --git a/internal/agent/auth_hook_test.go b/internal/agent/auth_hook_test.go index 0d02e24..9b8d6e9 100644 --- a/internal/agent/auth_hook_test.go +++ b/internal/agent/auth_hook_test.go @@ -132,7 +132,7 @@ func TestEnrollmentAuthRegistersAgentThenWhoamiUsesTheEnrolledIdentity(t *testin whoami := authHookEnvelope{ API: "realmroot", Profile: "default", - Requirements: []authRequirement{{ID: oauth2SchemeID, Kind: "oauth2-dpop", Needs: []string{"agent:read"}}}, + Requirements: []authRequirement{{ID: oauth2SchemeID, Kind: "oauth2", Needs: []string{"agent:read"}}}, Request: plugin.HookRequest{Method: http.MethodGet, URI: "https://auth.example.com/api/agent"}, } output, err = authenticateHookRequest(whoami, states, client, prompt) @@ -166,7 +166,7 @@ func testWhoamiWithoutLocalRegistrationRequiresExplicitEnrollment(t *testing.T) prompt := &promptRecorder{} _, err := authenticateHookRequest(authHookEnvelope{ API: "realmroot", Profile: "default", - Requirements: []authRequirement{{ID: oauth2SchemeID, Kind: "oauth2-dpop", Needs: []string{"agent:read"}}}, + Requirements: []authRequirement{{ID: oauth2SchemeID, Kind: "oauth2", Needs: []string{"agent:read"}}}, Request: plugin.HookRequest{Method: http.MethodGet, URI: "https://auth.example.com/api/agent"}, }, &memoryStateStore{}, client, prompt) if err == nil || !strings.Contains(err.Error(), "realmroot agent enroll") { diff --git a/internal/agent/protocol_resolver.go b/internal/agent/protocol_resolver.go index 0d448cd..cba2c06 100644 --- a/internal/agent/protocol_resolver.go +++ b/internal/agent/protocol_resolver.go @@ -106,7 +106,7 @@ func supportedProtocolAlternative(requirements []authRequirement) bool { switch requirements[0].ID { case oauth2SchemeID: switch requirements[0].Kind { - case "oauth2-dpop", "oauth2", "openid": + case "oauth2", "openid": return true } case agentAssertionSchemeID: diff --git a/internal/agent/protocol_resolver_test.go b/internal/agent/protocol_resolver_test.go index 3e84a40..d0b5de1 100644 --- a/internal/agent/protocol_resolver_test.go +++ b/internal/agent/protocol_resolver_test.go @@ -20,9 +20,14 @@ func TestSupportedProtocolAlternativeSeparatesEnrollmentFromResourceOAuth(t *tes }, { name: "Resource OAuth scheme with an Agent bootstrap scope", - requirements: []authRequirement{{ID: "oauth2", Kind: "oauth2-dpop", Needs: []string{"resource-servers:read"}}}, + requirements: []authRequirement{{ID: "oauth2", Kind: "oauth2", Needs: []string{"resource-servers:read"}}}, want: true, }, + { + name: "obsolete OAuth DPoP extension kind", + requirements: []authRequirement{{ID: "oauth2", Kind: "oauth2-dpop", Needs: []string{"resource-servers:read"}}}, + want: false, + }, { name: "Agent enrollment assertion", requirements: []authRequirement{{ID: "agentAssertion", Kind: "http-bearer"}}, diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index b36b6dc..571f6cb 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -507,13 +507,14 @@ func TestSelectedGenericOperationUsesConfiguredOperationBase(t *testing.T) { } func TestBindProfileCredentialsSupportsGenericHTTPRequests(t *testing.T) { + // [spec: cli/direct-resource-operation] config := &restish.Config{APIs: map[string]*restish.APIConfig{"platform": {}}} binding := agent.CredentialBinding{Reference: "selected-reference", Scopes: []string{"resource-servers:read"}} if err := bindProfileCredentials(config, catalog.ResourceServer{CommandName: "platform"}, githubOperationInspection(), "default", binding); err != nil { t.Fatal(err) } credential := config.APIs["platform"].Profiles["default"].Credentials["realmrootOidc"] - if credential == nil || credential.Auth == nil || credential.Auth.Params["reference"] != binding.Reference || strings.Join(credential.Satisfies, " ") != "resource-servers:read" { + if credential == nil || credential.Auth == nil || credential.Auth.Type != "dpop" || credential.Auth.Params["reference"] != binding.Reference || strings.Join(credential.Satisfies, " ") != "resource-servers:read" { t.Fatalf("credential = %#v", credential) } } @@ -525,6 +526,18 @@ func TestOperationScopeAlternativesPreserveOAuthAlternatives(t *testing.T) { } } +func TestOperationAuthorityRejectsObsoleteOAuthDPoPKind(t *testing.T) { + operation := restish.OperationInspection{CredentialAlternatives: [][]restish.CredentialRequirementInspection{{{ + ID: "oauth2", Kind: "oauth2-dpop", Needs: []string{"applications:read"}, + }}}} + if got := operationCredentialScopeAlternatives(operation); len(got) != 0 { + t.Fatalf("obsolete scope alternatives = %#v", got) + } + if operationCoveredByScopes(operation, []string{"applications:read"}) { + t.Fatal("obsolete OAuth DPoP extension kind accepted Agent authority") + } +} + type recordingOperationCredentialResolver struct { binding agent.CredentialBinding err error @@ -650,8 +663,8 @@ func githubOperationInspection() restish.APIInspection { return restish.APIInspection{Operations: []restish.OperationInspection{{ ID: "issuesGet", Command: []string{"issues", "issues-get"}, Method: "GET", Path: "/repos/{owner}/{repo}/issues/{number}", CredentialAlternatives: [][]restish.CredentialRequirementInspection{ - {{ID: "realmrootOidc", Kind: "oauth2-dpop", Needs: []string{"issues:read"}}}, - {{ID: "realmrootOidc", Kind: "oauth2-dpop", Needs: []string{"metadata:read"}}}, + {{ID: "realmrootOidc", Kind: "oauth2", Needs: []string{"issues:read"}}}, + {{ID: "realmrootOidc", Kind: "oauth2", Needs: []string{"metadata:read"}}}, }, }}} } @@ -770,8 +783,8 @@ func TestScopeFilterKeepsOnlyMatchingScopeAlternativesAndHidesCredentialSchemes( operations := []restish.OperationInspection{{ ID: "getContent", Command: []string{"repos", "get-content"}, Method: "GET", Path: "/repos/{owner}/{repo}/contents/{path}", CredentialAlternatives: [][]restish.CredentialRequirementInspection{ - {{ID: "realmrootOidc", Kind: "oauth2-dpop", Needs: []string{"contents:read"}}}, - {{ID: "realmrootOidc", Kind: "oauth2-dpop", Needs: []string{"metadata:read"}}}, + {{ID: "realmrootOidc", Kind: "oauth2", Needs: []string{"contents:read"}}}, + {{ID: "realmrootOidc", Kind: "oauth2", Needs: []string{"metadata:read"}}}, }, }} overview := buildResourceServerOverview(catalog.ResourceServer{CommandName: "github"}, nil, operations, discoveryOptions{Scope: "contents:read"}) @@ -783,7 +796,7 @@ func TestScopeFilterKeepsOnlyMatchingScopeAlternativesAndHidesCredentialSchemes( if err != nil { t.Fatal(err) } - for _, internal := range []string{"realmrootOidc", "oauth2-dpop", "metadata:read", "credentialAlternatives"} { + for _, internal := range []string{"realmrootOidc", "metadata:read", "credentialAlternatives"} { if strings.Contains(string(encoded), internal) { t.Fatalf("overview JSON exposed %q: %s", internal, encoded) } diff --git a/internal/cli/operation_authority.go b/internal/cli/operation_authority.go index 36db667..97f6c17 100644 --- a/internal/cli/operation_authority.go +++ b/internal/cli/operation_authority.go @@ -294,7 +294,7 @@ func bindProfileCredentials( for _, candidate := range inspection.Operations { for _, alternative := range candidate.CredentialAlternatives { for _, requirement := range alternative { - if requirement.Kind != "oauth2-dpop" { + if requirement.Kind != "oauth2" { continue } profile.Credentials[requirement.ID] = &restishconfig.CredentialConfig{ @@ -312,7 +312,7 @@ func operationCredentialScopeAlternatives(operation restish.OperationInspection) scopes := make([]string, 0) supported := len(alternative) > 0 for _, requirement := range alternative { - if requirement.Kind != "oauth2-dpop" { + if requirement.Kind != "oauth2" { supported = false break } @@ -379,7 +379,7 @@ func operationCoveredByScopes(operation restish.OperationInspection, scopes []st for _, alternative := range operation.CredentialAlternatives { covered := len(alternative) > 0 for _, requirement := range alternative { - if requirement.Kind != "oauth2-dpop" { + if requirement.Kind != "oauth2" { covered = false break } diff --git a/specs/cli.feature b/specs/cli.feature index 374f31f..2220821 100644 --- a/specs/cli.feature +++ b/specs/cli.feature @@ -121,10 +121,11 @@ Feature: Realmroot Toolbox command line @journey:direct-resource-operation @entrypoint:toolbox-operation Scenario: Invoke an OpenAPI-generated Resource operation Given one current cumulative credential is stored for each approved Resource Context + And the Resource Server publishes standard OAuth 2.0 security requirements When the Agent invokes the generated Toolbox operation Then Toolbox uses the selected Context's current cumulative authority And it never selects among historical access-request credentials - And Restish sends the request directly to the Resource Server with the selected proof-bound credential + And Restish sends the request directly to the Resource Server with the selected DPoP-bound credential And missing authority is reported using Realmroot Resource Server and scope vocabulary But embedded engine profiles, credential bindings, and setup commands are never exposed