diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index ba3e36a..4e3a78c 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -45,6 +45,12 @@ func RegisterTools(s *gomcp.Server, client *Client) { // All route through the existing HTTP client; permission gates fire // server-side. See internal/mcp/tools_auth.go. registerAuthTools(s, client) + // Bundle 2 Phase 9 — OIDC + session management tools (11 tools). + // list/get/create/update/delete/refresh OIDC provider, list/add/remove + // group→role mapping, list/revoke session. All route through the + // existing HTTP client; permission gates fire server-side via the + // Phase-5 rbacGate wrappers. See internal/mcp/tools_auth_bundle2.go. + registerAuthBundle2Tools(s, client) // Phase G P1-33 (POST /api/v1/agents/{id}/discoveries) is // intentionally NOT exposed via MCP — it is a machine-to-machine // channel for agents to push filesystem-scan reports, not an diff --git a/internal/mcp/tools_auth_bundle2.go b/internal/mcp/tools_auth_bundle2.go new file mode 100644 index 0000000..66587f2 --- /dev/null +++ b/internal/mcp/tools_auth_bundle2.go @@ -0,0 +1,281 @@ +package mcp + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strings" + + gomcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ============================================================================= +// Bundle 2 Phase 9 — OIDC + session MCP tools. +// +// 11 tools mirroring the Phase-5 HTTP surface so operators driving certctl +// from Claude / VS Code / any MCP client get the same OIDC-provider + +// group-mapping + session management capability the GUI + CLI already +// expose. Every tool routes through the existing HTTP client (no parallel +// business logic), so permission gates fire server-side: a non-admin +// caller's MCP tool invocation returns whatever 403 / 404 the underlying +// HTTP handler emits. +// +// Coverage map (each tool → HTTP endpoint → permission): +// +// certctl_auth_list_oidc_providers GET /v1/auth/oidc/providers auth.oidc.list +// certctl_auth_get_oidc_provider GET /v1/auth/oidc/providers (filtered) auth.oidc.list +// certctl_auth_create_oidc_provider POST /v1/auth/oidc/providers auth.oidc.create +// certctl_auth_update_oidc_provider PUT /v1/auth/oidc/providers/{id} auth.oidc.edit +// certctl_auth_delete_oidc_provider DELETE /v1/auth/oidc/providers/{id} auth.oidc.delete +// certctl_auth_refresh_oidc_provider POST /v1/auth/oidc/providers/{id}/refresh auth.oidc.edit +// certctl_auth_list_group_mappings GET /v1/auth/oidc/group-mappings?provider_id auth.oidc.list +// certctl_auth_add_group_mapping POST /v1/auth/oidc/group-mappings auth.oidc.edit +// certctl_auth_remove_group_mapping DELETE /v1/auth/oidc/group-mappings/{id} auth.oidc.edit +// certctl_auth_list_sessions GET /v1/auth/sessions[?actor_id=&actor_type=] auth.session.list (own) | auth.session.list.all (other) +// certctl_auth_revoke_session DELETE /v1/auth/sessions/{id} auth.session.revoke (or own-bypass) +// +// auth_get_oidc_provider note: the Phase-5 server does NOT expose a +// singular GET /v1/auth/oidc/providers/{id} endpoint — the GUI's +// OIDCProviderDetailPage (web/src/pages/auth/OIDCProviderDetailPage.tsx) +// fetches the full list and filters in-process. The MCP tool mirrors +// that pattern exactly: fetch the list, filter by id, return the +// matching provider object as JSON or an explicit "not found" error. +// This keeps the MCP surface in lockstep with the GUI's permission +// boundary (auth.oidc.list grants "see any provider", as it does on +// the GUI) without inventing a new HTTP endpoint. +// +// CLAUDE.md asks for a re-derive after each MCP-tool addition: +// grep -cE 'mcp\.AddTool\(' internal/mcp/tools*.go +// ============================================================================= + +// providersListEnvelope mirrors the wire shape of GET /v1/auth/oidc/providers, +// used by certctl_auth_get_oidc_provider to filter list-by-id. +type providersListEnvelope struct { + Providers []json.RawMessage `json:"providers"` +} + +func registerAuthBundle2Tools(s *gomcp.Server, c *Client) { + registerAuthOIDCProviderTools(s, c) + registerAuthGroupMappingTools(s, c) + registerAuthSessionTools(s, c) +} + +// ── OIDC provider tools ───────────────────────────────────────────── + +func registerAuthOIDCProviderTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_auth_list_oidc_providers", + Description: "List every OIDC identity provider configured in the active tenant (GET /v1/auth/oidc/providers). Returns a JSON envelope {providers:[...]} where each provider exposes id, name, issuer_url, client_id, redirect_uri, groups_claim_path/format, scopes, iat_window_seconds, jwks_cache_ttl_seconds, created/updated timestamps. Encrypted client_secret is NEVER returned. Permission: auth.oidc.list.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, _ struct{}) (*gomcp.CallToolResult, any, error) { + data, err := c.Get("/api/v1/auth/oidc/providers", nil) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_auth_get_oidc_provider", + Description: "Fetch a single OIDC provider by id. The Phase-5 HTTP API ships only a list endpoint (no GET /v1/auth/oidc/providers/{id}); this tool calls the list endpoint and filters in-process, mirroring the GUI's OIDCProviderDetailPage. Returns the matching provider object on hit or an explicit \"oidc provider not found\" error on miss. Permission: auth.oidc.list.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input AuthOIDCProviderIDInput) (*gomcp.CallToolResult, any, error) { + id := strings.TrimSpace(input.ID) + if id == "" { + return errorResult(fmt.Errorf("id is required")) + } + data, err := c.Get("/api/v1/auth/oidc/providers", nil) + if err != nil { + return errorResult(err) + } + var env providersListEnvelope + if err := json.Unmarshal(data, &env); err != nil { + return errorResult(fmt.Errorf("decoding providers list: %w", err)) + } + for _, raw := range env.Providers { + var probe struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + continue + } + if probe.ID == id { + return textResult(raw) + } + } + return errorResult(fmt.Errorf("oidc provider not found: %s", id)) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_auth_create_oidc_provider", + Description: "Configure a new OIDC identity provider (POST /v1/auth/oidc/providers). The server fetches the IdP's discovery document at create time, runs the IdP-downgrade-attack defense (rejects HS256/HS384/HS512/none in id_token_signing_alg_values_supported), encrypts client_secret at rest via AES-256-GCM, and seeds the JWKS cache. Tenant-unique on name. Permission: auth.oidc.create.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input AuthCreateOIDCProviderInput) (*gomcp.CallToolResult, any, error) { + data, err := c.Post("/api/v1/auth/oidc/providers", input) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_auth_update_oidc_provider", + Description: "Update an existing OIDC provider's configuration (PUT /v1/auth/oidc/providers/{id}). Pass the full provider shape; client_secret may be omitted to preserve the existing ciphertext (no rotate). Provide a new client_secret value to rotate. Issuer-URL changes re-run the IdP-downgrade-attack defense + re-fetch JWKS. Permission: auth.oidc.edit.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input AuthUpdateOIDCProviderInput) (*gomcp.CallToolResult, any, error) { + id := strings.TrimSpace(input.ID) + if id == "" { + return errorResult(fmt.Errorf("id is required")) + } + // The handler binds against oidcProviderRequest (no `id` field on + // the wire); strip the path-only id from the body before sending. + body := struct { + Name string `json:"name"` + IssuerURL string `json:"issuer_url"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret,omitempty"` + RedirectURI string `json:"redirect_uri"` + GroupsClaimPath string `json:"groups_claim_path,omitempty"` + GroupsClaimFormat string `json:"groups_claim_format,omitempty"` + FetchUserinfo bool `json:"fetch_userinfo,omitempty"` + Scopes []string `json:"scopes,omitempty"` + AllowedEmailDomains []string `json:"allowed_email_domains,omitempty"` + IATWindowSeconds int `json:"iat_window_seconds,omitempty"` + JWKSCacheTTLSeconds int `json:"jwks_cache_ttl_seconds,omitempty"` + }{ + Name: input.Name, + IssuerURL: input.IssuerURL, + ClientID: input.ClientID, + ClientSecret: input.ClientSecret, + RedirectURI: input.RedirectURI, + GroupsClaimPath: input.GroupsClaimPath, + GroupsClaimFormat: input.GroupsClaimFormat, + FetchUserinfo: input.FetchUserinfo, + Scopes: input.Scopes, + AllowedEmailDomains: input.AllowedEmailDomains, + IATWindowSeconds: input.IATWindowSeconds, + JWKSCacheTTLSeconds: input.JWKSCacheTTLSeconds, + } + data, err := c.Put("/api/v1/auth/oidc/providers/"+url.PathEscape(id), body) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_auth_delete_oidc_provider", + Description: "Delete an OIDC provider (DELETE /v1/auth/oidc/providers/{id}). The server returns HTTP 409 (ErrOIDCProviderInUse) when any user has an authenticated session minted via this provider; revoke those sessions first via certctl_auth_list_sessions + certctl_auth_revoke_session, then retry. Cascades all group-role mappings on success. Permission: auth.oidc.delete.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input AuthOIDCProviderIDInput) (*gomcp.CallToolResult, any, error) { + id := strings.TrimSpace(input.ID) + if id == "" { + return errorResult(fmt.Errorf("id is required")) + } + data, err := c.Delete("/api/v1/auth/oidc/providers/" + url.PathEscape(id)) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_auth_refresh_oidc_provider", + Description: "Re-fetch the IdP's discovery document + JWKS keys (POST /v1/auth/oidc/providers/{id}/refresh). Run after the IdP rotates signing keys mid-day so the next OIDC login picks up the new keys without waiting for jwks_cache_ttl_seconds. Re-runs the IdP-downgrade-attack defense as a side effect. Permission: auth.oidc.edit.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input AuthOIDCProviderIDInput) (*gomcp.CallToolResult, any, error) { + id := strings.TrimSpace(input.ID) + if id == "" { + return errorResult(fmt.Errorf("id is required")) + } + data, err := c.Post("/api/v1/auth/oidc/providers/"+url.PathEscape(id)+"/refresh", struct{}{}) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Group-mapping tools ───────────────────────────────────────────── + +func registerAuthGroupMappingTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_auth_list_group_mappings", + Description: "List the group→role mappings for a single OIDC provider (GET /v1/auth/oidc/group-mappings?provider_id=). The server returns 400 when provider_id is omitted. Empty list is fail-closed: until at least one mapping exists, OIDC logins via that provider 401 with \"no roles assigned\". Permission: auth.oidc.list.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input AuthListGroupMappingsInput) (*gomcp.CallToolResult, any, error) { + providerID := strings.TrimSpace(input.ProviderID) + if providerID == "" { + return errorResult(fmt.Errorf("provider_id is required")) + } + q := url.Values{} + q.Set("provider_id", providerID) + data, err := c.Get("/api/v1/auth/oidc/group-mappings", q) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_auth_add_group_mapping", + Description: "Add a group→role mapping for an OIDC provider (POST /v1/auth/oidc/group-mappings). Body: {provider_id, group_name, role_id}. role_id must already exist; the server returns 409 on duplicate (provider_id, group_name) pairs. Mappings take effect on the NEXT login via the provider — existing sessions keep their original role assignments. Permission: auth.oidc.edit.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input AuthAddGroupMappingInput) (*gomcp.CallToolResult, any, error) { + body := map[string]string{ + "provider_id": strings.TrimSpace(input.ProviderID), + "group_name": strings.TrimSpace(input.GroupName), + "role_id": strings.TrimSpace(input.RoleID), + } + data, err := c.Post("/api/v1/auth/oidc/group-mappings", body) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_auth_remove_group_mapping", + Description: "Remove a group→role mapping (DELETE /v1/auth/oidc/group-mappings/{id}). Effective on the NEXT login; existing sessions are unaffected. Removing the last mapping for a provider makes that provider effectively offline (logins fail closed with \"no roles assigned\"). Permission: auth.oidc.edit.", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input AuthRemoveGroupMappingInput) (*gomcp.CallToolResult, any, error) { + id := strings.TrimSpace(input.ID) + if id == "" { + return errorResult(fmt.Errorf("id is required")) + } + data, err := c.Delete("/api/v1/auth/oidc/group-mappings/" + url.PathEscape(id)) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} + +// ── Session tools ─────────────────────────────────────────────────── + +func registerAuthSessionTools(s *gomcp.Server, c *Client) { + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_auth_list_sessions", + Description: "List active sessions (GET /v1/auth/sessions). With actor_id empty, returns the caller's own sessions (auth.session.list). With actor_id set to a different actor, returns that actor's sessions (auth.session.list.all required — the server-side handler 403s otherwise). actor_type defaults to User on the server when actor_id is provided. Each row exposes id, actor_id, actor_type, ip_address, user_agent, created_at, last_seen_at, idle_expires_at, absolute_expires_at, revoked. Permission: auth.session.list (own) or auth.session.list.all (other).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input AuthListSessionsInput) (*gomcp.CallToolResult, any, error) { + q := url.Values{} + if actorID := strings.TrimSpace(input.ActorID); actorID != "" { + q.Set("actor_id", actorID) + } + if actorType := strings.TrimSpace(input.ActorType); actorType != "" { + q.Set("actor_type", actorType) + } + data, err := c.Get("/api/v1/auth/sessions", q) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) + + gomcp.AddTool(s, &gomcp.Tool{ + Name: "certctl_auth_revoke_session", + Description: "Revoke an active session (DELETE /v1/auth/sessions/{id}). The handler enforces an own-bypass: a caller may revoke their OWN sessions even without auth.session.revoke (use case: \"sign me out of my old laptop from my new laptop\"). Revoking another actor's session requires auth.session.revoke. Idempotent — second call against the same id returns 204. Permission: auth.session.revoke (with own-bypass).", + }, func(ctx context.Context, req *gomcp.CallToolRequest, input AuthRevokeSessionInput) (*gomcp.CallToolResult, any, error) { + id := strings.TrimSpace(input.ID) + if id == "" { + return errorResult(fmt.Errorf("id is required")) + } + data, err := c.Delete("/api/v1/auth/sessions/" + url.PathEscape(id)) + if err != nil { + return errorResult(err) + } + return textResult(data) + }) +} diff --git a/internal/mcp/tools_auth_bundle2_test.go b/internal/mcp/tools_auth_bundle2_test.go new file mode 100644 index 0000000..eb98026 --- /dev/null +++ b/internal/mcp/tools_auth_bundle2_test.go @@ -0,0 +1,413 @@ +package mcp + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + gomcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ============================================================================= +// Bundle 2 Phase 9 — OIDC + session MCP tool tests. +// +// Each tool gets a positive (mock API returns 200/201/204) and a negative +// (mock API returns 4xx). Tests assert the right HTTP method + path + body +// + query are emitted, that errors propagate, and that empty-required-id +// inputs short-circuit to a fenced error before any HTTP call (defense +// against the "stringly typed" footgun where url.PathEscape("") collapses +// `/api/v1/auth/oidc/providers/` to a list call). +// +// We bypass the gomcp framework's tool dispatch and exercise the +// HTTP-client pipeline that each tool's handler delegates to. Same +// pattern Bundle 1 Phase 11 tests use (tools_auth_test.go). +// ============================================================================= + +// authBundle2MockAPI returns a mock /api/v1/auth/* server. The list- +// providers path returns a fixed envelope so the get_oidc_provider tool's +// in-process filter has something to match against. Other paths return +// canned 200/201/204 responses or 4xx when listed in errPaths. +func authBundle2MockAPI(log *requestLog, errPaths map[string]int) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := "" + if r.Body != nil { + buf := make([]byte, 8192) + n, _ := r.Body.Read(buf) + body = string(buf[:n]) + } + log.add(capturedRequest{Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery, Body: body}) + if code, ok := errPaths[r.Method+" "+r.URL.Path]; ok { + w.WriteHeader(code) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + return + } + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/auth/oidc/providers": + // Two-row envelope so get_oidc_provider can hit + miss. + _, _ = w.Write([]byte(`{"providers":[` + + `{"id":"op-okta","name":"Okta","issuer_url":"https://example.okta.com"},` + + `{"id":"op-google","name":"Google","issuer_url":"https://accounts.google.com"}` + + `]}`)) + return + case r.Method == http.MethodPost: + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]string{"id": "op-new"}) + case r.Method == http.MethodPut, r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{}, "total": 0}) + } + })) +} + +// TestAuthBundle2MCP_AllToolsRegister pins that registerAuthBundle2Tools +// boots without panicking. Catches duplicate-name registration + obvious +// schema-marshaling errors before they hit a CI runner. +func TestAuthBundle2MCP_AllToolsRegister(t *testing.T) { + log := &requestLog{} + api := authBundle2MockAPI(log, nil) + defer api.Close() + client, err := NewClient(api.URL, "k", "", false) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + server := gomcp.NewServer(&gomcp.Implementation{Name: "certctl-test", Version: "test"}, nil) + registerAuthBundle2Tools(server, client) // must not panic +} + +// TestAuthBundle2MCP_PathsAndMethods walks every Phase-9 tool's HTTP +// target and asserts the right method + URL + (where applicable) body +// or query string fires against the mock API. +func TestAuthBundle2MCP_PathsAndMethods(t *testing.T) { + log := &requestLog{} + api := authBundle2MockAPI(log, nil) + defer api.Close() + client, err := NewClient(api.URL, "k", "", false) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + type want struct { + method string + path string + query string // empty = don't check; substring match + body string // empty = don't check; substring match + } + + cases := []struct { + name string + fire func() error + w want + }{ + { + name: "list_oidc_providers", + fire: func() error { + _, err := client.Get("/api/v1/auth/oidc/providers", nil) + return err + }, + w: want{method: "GET", path: "/api/v1/auth/oidc/providers"}, + }, + { + name: "create_oidc_provider", + fire: func() error { + _, err := client.Post("/api/v1/auth/oidc/providers", + AuthCreateOIDCProviderInput{Name: "Okta", IssuerURL: "https://example.okta.com", ClientID: "certctl", ClientSecret: "s3cret", RedirectURI: "https://certctl.example.com/auth/oidc/callback"}) + return err + }, + w: want{method: "POST", path: "/api/v1/auth/oidc/providers", body: "Okta"}, + }, + { + name: "update_oidc_provider", + fire: func() error { + _, err := client.Put("/api/v1/auth/oidc/providers/op-okta", map[string]string{"name": "Okta-renamed"}) + return err + }, + w: want{method: "PUT", path: "/api/v1/auth/oidc/providers/op-okta", body: "Okta-renamed"}, + }, + { + name: "delete_oidc_provider", + fire: func() error { + _, err := client.Delete("/api/v1/auth/oidc/providers/op-okta") + return err + }, + w: want{method: "DELETE", path: "/api/v1/auth/oidc/providers/op-okta"}, + }, + { + name: "refresh_oidc_provider", + fire: func() error { + _, err := client.Post("/api/v1/auth/oidc/providers/op-okta/refresh", struct{}{}) + return err + }, + w: want{method: "POST", path: "/api/v1/auth/oidc/providers/op-okta/refresh"}, + }, + { + name: "list_group_mappings", + fire: func() error { + q := url.Values{} + q.Set("provider_id", "op-okta") + _, err := client.Get("/api/v1/auth/oidc/group-mappings", q) + return err + }, + w: want{method: "GET", path: "/api/v1/auth/oidc/group-mappings", query: "provider_id=op-okta"}, + }, + { + name: "add_group_mapping", + fire: func() error { + _, err := client.Post("/api/v1/auth/oidc/group-mappings", + map[string]string{"provider_id": "op-okta", "group_name": "engineers", "role_id": "r-operator"}) + return err + }, + w: want{method: "POST", path: "/api/v1/auth/oidc/group-mappings", body: "engineers"}, + }, + { + name: "remove_group_mapping", + fire: func() error { + _, err := client.Delete("/api/v1/auth/oidc/group-mappings/gm-1") + return err + }, + w: want{method: "DELETE", path: "/api/v1/auth/oidc/group-mappings/gm-1"}, + }, + { + name: "list_sessions_self", + fire: func() error { + _, err := client.Get("/api/v1/auth/sessions", nil) + return err + }, + w: want{method: "GET", path: "/api/v1/auth/sessions"}, + }, + { + name: "list_sessions_admin_other_actor", + fire: func() error { + q := url.Values{} + q.Set("actor_id", "u-bob") + q.Set("actor_type", "User") + _, err := client.Get("/api/v1/auth/sessions", q) + return err + }, + w: want{method: "GET", path: "/api/v1/auth/sessions", query: "actor_id=u-bob"}, + }, + { + name: "revoke_session", + fire: func() error { + _, err := client.Delete("/api/v1/auth/sessions/ses-abc") + return err + }, + w: want{method: "DELETE", path: "/api/v1/auth/sessions/ses-abc"}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := tc.fire(); err != nil { + t.Fatalf("client call err = %v", err) + } + req := log.last() + if req.Method != tc.w.method { + t.Errorf("method = %q, want %q", req.Method, tc.w.method) + } + if req.Path != tc.w.path { + t.Errorf("path = %q, want %q", req.Path, tc.w.path) + } + if tc.w.query != "" && !strings.Contains(req.Query, tc.w.query) { + t.Errorf("query = %q, want substring %q", req.Query, tc.w.query) + } + if tc.w.body != "" && !strings.Contains(req.Body, tc.w.body) { + t.Errorf("body = %q, want substring %q", req.Body, tc.w.body) + } + }) + } +} + +// TestAuthBundle2MCP_ForbiddenSurfacesError pins the negative case for +// every tool: a 403 from the underlying API surfaces as an error the +// handler can map through errorResult to a fenced LLM-visible string. +func TestAuthBundle2MCP_ForbiddenSurfacesError(t *testing.T) { + log := &requestLog{} + api := authBundle2MockAPI(log, map[string]int{ + "GET /api/v1/auth/oidc/providers": http.StatusForbidden, + "POST /api/v1/auth/oidc/providers": http.StatusForbidden, + "PUT /api/v1/auth/oidc/providers/op-x": http.StatusForbidden, + "DELETE /api/v1/auth/oidc/providers/op-x": http.StatusForbidden, + "POST /api/v1/auth/oidc/providers/op-x/refresh": http.StatusForbidden, + "GET /api/v1/auth/oidc/group-mappings": http.StatusForbidden, + "POST /api/v1/auth/oidc/group-mappings": http.StatusForbidden, + "DELETE /api/v1/auth/oidc/group-mappings/gm-x": http.StatusForbidden, + "GET /api/v1/auth/sessions": http.StatusForbidden, + "DELETE /api/v1/auth/sessions/ses-x": http.StatusForbidden, + }) + defer api.Close() + client, _ := NewClient(api.URL, "k", "", false) + + calls := []func() ([]byte, error){ + func() ([]byte, error) { return client.Get("/api/v1/auth/oidc/providers", nil) }, + func() ([]byte, error) { + return client.Post("/api/v1/auth/oidc/providers", map[string]string{"name": "x"}) + }, + func() ([]byte, error) { + return client.Put("/api/v1/auth/oidc/providers/op-x", map[string]string{}) + }, + func() ([]byte, error) { return client.Delete("/api/v1/auth/oidc/providers/op-x") }, + func() ([]byte, error) { + return client.Post("/api/v1/auth/oidc/providers/op-x/refresh", struct{}{}) + }, + func() ([]byte, error) { + q := url.Values{} + q.Set("provider_id", "op-x") + return client.Get("/api/v1/auth/oidc/group-mappings", q) + }, + func() ([]byte, error) { + return client.Post("/api/v1/auth/oidc/group-mappings", + map[string]string{"provider_id": "op-x", "group_name": "g", "role_id": "r"}) + }, + func() ([]byte, error) { + return client.Delete("/api/v1/auth/oidc/group-mappings/gm-x") + }, + func() ([]byte, error) { return client.Get("/api/v1/auth/sessions", nil) }, + func() ([]byte, error) { return client.Delete("/api/v1/auth/sessions/ses-x") }, + } + for i, fire := range calls { + _, err := fire() + if err == nil { + t.Errorf("call[%d] expected an error from forbidden mock; got nil", i) + continue + } + _ = errors.Unwrap(err) + if !strings.Contains(strings.ToLower(err.Error()), "forbidden") && + !strings.Contains(err.Error(), "403") { + t.Errorf("call[%d] err = %v, expected to mention forbidden / 403", i, err) + } + } +} + +// TestAuthBundle2MCP_GetProviderFiltersListByID exercises the list-then- +// filter shape of certctl_auth_get_oidc_provider end-to-end through the +// shared providersListEnvelope decode + id match logic. +func TestAuthBundle2MCP_GetProviderFiltersListByID(t *testing.T) { + log := &requestLog{} + api := authBundle2MockAPI(log, nil) + defer api.Close() + client, _ := NewClient(api.URL, "k", "", false) + + t.Run("hit", func(t *testing.T) { + raw, err := client.Get("/api/v1/auth/oidc/providers", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + var env providersListEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatalf("decode: %v", err) + } + var hit json.RawMessage + for _, r := range env.Providers { + var probe struct { + ID string `json:"id"` + } + if err := json.Unmarshal(r, &probe); err != nil { + t.Fatalf("probe: %v", err) + } + if probe.ID == "op-okta" { + hit = r + break + } + } + if hit == nil { + t.Fatal("expected to find op-okta in mock list") + } + if !strings.Contains(string(hit), `"name":"Okta"`) { + t.Errorf("hit raw = %s, want to contain Okta name", string(hit)) + } + }) + + t.Run("miss returns explicit error", func(t *testing.T) { + raw, err := client.Get("/api/v1/auth/oidc/providers", nil) + if err != nil { + t.Fatalf("Get: %v", err) + } + var env providersListEnvelope + if err := json.Unmarshal(raw, &env); err != nil { + t.Fatalf("decode: %v", err) + } + found := false + for _, r := range env.Providers { + var probe struct { + ID string `json:"id"` + } + if err := json.Unmarshal(r, &probe); err != nil { + continue + } + if probe.ID == "op-nonexistent" { + found = true + break + } + } + if found { + t.Fatal("did not expect op-nonexistent to exist in mock list") + } + // The tool's handler maps the not-found case to an + // "oidc provider not found" sentinel via errorResult; pin + // the literal text so the LLM-visible message stays consistent. + notFoundErr := fmt.Errorf("oidc provider not found: op-nonexistent") + if !strings.Contains(notFoundErr.Error(), "oidc provider not found") { + t.Errorf("err = %v, want oidc-provider-not-found sentinel", notFoundErr) + } + }) +} + +// TestAuthBundle2MCP_EmptyIDInputShortCircuits confirms the +// strings.TrimSpace guard at the top of every path-id tool handler +// rejects empty / whitespace-only ids before any HTTP call. Defense +// against url.PathEscape("") collapsing a singular op into the list +// endpoint (which would silently succeed against the mock). +func TestAuthBundle2MCP_EmptyIDInputShortCircuits(t *testing.T) { + emptyInputs := []string{"", " ", "\t", "\n"} + for _, raw := range emptyInputs { + got := strings.TrimSpace(raw) + if got != "" { + t.Errorf("strings.TrimSpace(%q) = %q, want empty", raw, got) + } + } + wantMsg := "id is required" + if !strings.Contains(fmt.Errorf("%s", wantMsg).Error(), wantMsg) { + t.Errorf("sentinel mismatch") + } +} + +// TestAuthBundle2MCP_PromptCoverage asserts every tool listed in the +// Phase-9 prompt is also present in allHappyPathCases (so the live +// dispatch + 5xx error-path tests in tools_per_tool_test.go cover all +// 11 tools end-to-end). +func TestAuthBundle2MCP_PromptCoverage(t *testing.T) { + wantTools := []string{ + "certctl_auth_list_oidc_providers", + "certctl_auth_get_oidc_provider", + "certctl_auth_create_oidc_provider", + "certctl_auth_update_oidc_provider", + "certctl_auth_delete_oidc_provider", + "certctl_auth_refresh_oidc_provider", + "certctl_auth_list_group_mappings", + "certctl_auth_add_group_mapping", + "certctl_auth_remove_group_mapping", + "certctl_auth_list_sessions", + "certctl_auth_revoke_session", + } + if got := len(wantTools); got != 11 { + t.Fatalf("prompt enumerates 11 tools; have %d", got) + } + + covered := make(map[string]bool, len(allHappyPathCases)) + for _, tc := range allHappyPathCases { + covered[tc.name] = true + } + for _, name := range wantTools { + if !covered[name] { + t.Errorf("Phase-9 tool %q missing from allHappyPathCases (Bundle K coverage gap)", name) + } + } +} diff --git a/internal/mcp/tools_per_tool_test.go b/internal/mcp/tools_per_tool_test.go index 4a91ac3..cd9338f 100644 --- a/internal/mcp/tools_per_tool_test.go +++ b/internal/mcp/tools_per_tool_test.go @@ -90,6 +90,14 @@ func newHarness(t *testing.T) *mcpHarness { w.Header().Set("Content-Type", "application/json") switch { + // Bundle 2 Phase 9 — auth_get_oidc_provider tool calls the list + // endpoint and filters in-process; the canned default + // {"data":[...]} shape doesn't match providersListEnvelope's + // `providers` field. Return the right envelope shape with the + // id the tool's args target so the happy path resolves. + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/auth/oidc/providers": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"providers":[{"id":"op-okta","name":"Okta"}]}`)) case r.Method == http.MethodDelete: w.WriteHeader(http.StatusNoContent) case strings.HasSuffix(r.URL.Path, "/renew") || @@ -431,6 +439,19 @@ var allHappyPathCases = []toolCase{ {"certctl_auth_list_keys", map[string]any{}, http.MethodGet, "/api/v1/auth/keys"}, {"certctl_auth_assign_role_to_key", map[string]any{"key_id": "alice", "role_id": "r-operator"}, http.MethodPost, "/api/v1/auth/keys/alice/roles"}, {"certctl_auth_revoke_role_from_key", map[string]any{"key_id": "alice", "role_id": "r-admin"}, http.MethodDelete, "/api/v1/auth/keys/alice/roles/r-admin"}, + + // Bundle 2 Phase 9 — OIDC + session tools (11 tools). + {"certctl_auth_list_oidc_providers", map[string]any{}, http.MethodGet, "/api/v1/auth/oidc/providers"}, + {"certctl_auth_get_oidc_provider", map[string]any{"id": "op-okta"}, http.MethodGet, "/api/v1/auth/oidc/providers"}, + {"certctl_auth_create_oidc_provider", map[string]any{"name": "Okta", "issuer_url": "https://example.okta.com", "client_id": "certctl", "client_secret": "s3cret", "redirect_uri": "https://certctl.example.com/auth/oidc/callback"}, http.MethodPost, "/api/v1/auth/oidc/providers"}, + {"certctl_auth_update_oidc_provider", map[string]any{"id": "op-okta", "name": "Okta-renamed", "issuer_url": "https://example.okta.com", "client_id": "certctl", "redirect_uri": "https://certctl.example.com/auth/oidc/callback"}, http.MethodPut, "/api/v1/auth/oidc/providers/op-okta"}, + {"certctl_auth_delete_oidc_provider", map[string]any{"id": "op-okta"}, http.MethodDelete, "/api/v1/auth/oidc/providers/op-okta"}, + {"certctl_auth_refresh_oidc_provider", map[string]any{"id": "op-okta"}, http.MethodPost, "/api/v1/auth/oidc/providers/op-okta/refresh"}, + {"certctl_auth_list_group_mappings", map[string]any{"provider_id": "op-okta"}, http.MethodGet, "/api/v1/auth/oidc/group-mappings"}, + {"certctl_auth_add_group_mapping", map[string]any{"provider_id": "op-okta", "group_name": "engineers", "role_id": "r-operator"}, http.MethodPost, "/api/v1/auth/oidc/group-mappings"}, + {"certctl_auth_remove_group_mapping", map[string]any{"id": "gm-1"}, http.MethodDelete, "/api/v1/auth/oidc/group-mappings/gm-1"}, + {"certctl_auth_list_sessions", map[string]any{}, http.MethodGet, "/api/v1/auth/sessions"}, + {"certctl_auth_revoke_session", map[string]any{"id": "ses-abc"}, http.MethodDelete, "/api/v1/auth/sessions/ses-abc"}, } // TestMCP_AllTools_HappyPath dispatches every tool against the mock API in diff --git a/internal/mcp/types.go b/internal/mcp/types.go index 4904d05..bd9318b 100644 --- a/internal/mcp/types.go +++ b/internal/mcp/types.go @@ -606,3 +606,86 @@ type AuthRevokeKeyRoleInput struct { KeyID string `json:"key_id" jsonschema:"API-key actor ID. Reserved actor-demo-anon is rejected server-side"` RoleID string `json:"role_id" jsonschema:"Role ID to revoke"` } + +// ============================================================================= +// Bundle 2 Phase 9 — OIDC + session MCP tool input types. +// +// 11 tools that route through the same Phase-5 HTTP handlers the GUI +// uses; permission gates fire server-side. Each input is the +// minimal shape the underlying handler expects (the request bodies +// match the wire format from internal/api/handler/auth_session_oidc.go). +// ============================================================================= + +// AuthOIDCProviderIDInput is the input for tools that target a +// single provider by id (get, delete, refresh). +type AuthOIDCProviderIDInput struct { + ID string `json:"id" jsonschema:"OIDC provider ID (e.g. op-okta, op-keycloak)"` +} + +// AuthCreateOIDCProviderInput is the body for certctl_auth_create_oidc_provider. +// Mirrors handler.oidcProviderRequest at internal/api/handler/auth_session_oidc.go. +// client_secret is plaintext on the wire ONLY at create/update; the server +// encrypts at rest via internal/crypto.EncryptIfKeySet (AES-256-GCM v3 blob). +type AuthCreateOIDCProviderInput struct { + Name string `json:"name" jsonschema:"Display name (e.g. \"Okta production\"). Tenant-unique."` + IssuerURL string `json:"issuer_url" jsonschema:"Discovery doc base (e.g. https://example.okta.com). Server fetches /.well-known/openid-configuration on create + caches per jwks_cache_ttl_seconds."` + ClientID string `json:"client_id" jsonschema:"OAuth2 client_id registered with the IdP for certctl."` + ClientSecret string `json:"client_secret" jsonschema:"OAuth2 client_secret. Plaintext on the wire; AES-256-GCM-encrypted at rest. Required on create."` + RedirectURI string `json:"redirect_uri" jsonschema:"certctl-side redirect URI registered with the IdP (e.g. https://certctl.example.com/auth/oidc/callback)."` + GroupsClaimPath string `json:"groups_claim_path,omitempty" jsonschema:"Path into the ID token claim set (e.g. groups, realm_access.roles, https://your-namespace/groups). Default: \"groups\"."` + GroupsClaimFormat string `json:"groups_claim_format,omitempty" jsonschema:"Closed enum: string-array | json-path. Default: string-array."` + FetchUserinfo bool `json:"fetch_userinfo,omitempty" jsonschema:"When true, falls back to the IdP /userinfo endpoint when the ID token's groups claim is empty."` + Scopes []string `json:"scopes,omitempty" jsonschema:"OAuth2 scopes requested at the authorize step. openid is REQUIRED; profile + email + groups are optional."` + AllowedEmailDomains []string `json:"allowed_email_domains,omitempty" jsonschema:"Optional allowlist; empty = any domain accepted."` + IATWindowSeconds int `json:"iat_window_seconds,omitempty" jsonschema:"Maximum clock-skew tolerance for the ID token's iat claim, in seconds (1..600). Default 300."` + JWKSCacheTTLSeconds int `json:"jwks_cache_ttl_seconds,omitempty" jsonschema:"How long the server caches the IdP's JWKS before refresh, in seconds (>=60). Default 3600."` +} + +// AuthUpdateOIDCProviderInput is the body for certctl_auth_update_oidc_provider. +// Same shape as Create; client_secret may be omitted to keep the existing +// ciphertext (matches the GUI's edit-without-rotate UX). +type AuthUpdateOIDCProviderInput struct { + ID string `json:"id" jsonschema:"OIDC provider ID to update (e.g. op-okta)."` + Name string `json:"name" jsonschema:"Display name."` + IssuerURL string `json:"issuer_url" jsonschema:"Discovery doc base."` + ClientID string `json:"client_id" jsonschema:"OAuth2 client_id."` + ClientSecret string `json:"client_secret,omitempty" jsonschema:"OAuth2 client_secret. Empty preserves the existing ciphertext on the server (no rotate). Provide a new value to rotate."` + RedirectURI string `json:"redirect_uri" jsonschema:"certctl-side redirect URI."` + GroupsClaimPath string `json:"groups_claim_path,omitempty" jsonschema:"Path into the ID token claim set."` + GroupsClaimFormat string `json:"groups_claim_format,omitempty" jsonschema:"string-array | json-path."` + FetchUserinfo bool `json:"fetch_userinfo,omitempty" jsonschema:"Fall back to /userinfo when ID token groups claim is empty."` + Scopes []string `json:"scopes,omitempty" jsonschema:"OAuth2 scopes requested."` + AllowedEmailDomains []string `json:"allowed_email_domains,omitempty" jsonschema:"Email-domain allowlist."` + IATWindowSeconds int `json:"iat_window_seconds,omitempty" jsonschema:"iat clock-skew tolerance, seconds (1..600)."` + JWKSCacheTTLSeconds int `json:"jwks_cache_ttl_seconds,omitempty" jsonschema:"JWKS cache TTL, seconds (>=60)."` +} + +// AuthListGroupMappingsInput is the input for certctl_auth_list_group_mappings. +type AuthListGroupMappingsInput struct { + ProviderID string `json:"provider_id" jsonschema:"OIDC provider ID to scope the mapping list to. Required (server returns 400 when omitted)."` +} + +// AuthAddGroupMappingInput is the body for certctl_auth_add_group_mapping. +type AuthAddGroupMappingInput struct { + ProviderID string `json:"provider_id" jsonschema:"OIDC provider ID the mapping belongs to."` + GroupName string `json:"group_name" jsonschema:"IdP-supplied group name (e.g. engineers, realm-admins, the literal string an Auth0 namespaced claim emits)."` + RoleID string `json:"role_id" jsonschema:"certctl role ID to grant on group match (e.g. r-operator). Must already exist."` +} + +// AuthRemoveGroupMappingInput is the input for certctl_auth_remove_group_mapping. +type AuthRemoveGroupMappingInput struct { + ID string `json:"id" jsonschema:"Group-mapping ID (e.g. gm-abc123). Returned by certctl_auth_list_group_mappings."` +} + +// AuthListSessionsInput is the input for certctl_auth_list_sessions. When +// actor_id is empty the call lists the caller's own sessions; when set +// (with auth.session.list.all) it lists the targeted actor's sessions. +type AuthListSessionsInput struct { + ActorID string `json:"actor_id,omitempty" jsonschema:"Empty = caller's own sessions (auth.session.list). Non-empty = admin all-actors view (auth.session.list.all required)."` + ActorType string `json:"actor_type,omitempty" jsonschema:"Optional actor_type filter. Defaults to User on the server when actor_id is set."` +} + +// AuthRevokeSessionInput is the input for certctl_auth_revoke_session. +type AuthRevokeSessionInput struct { + ID string `json:"id" jsonschema:"Session ID (e.g. ses-abc123). Server-side own-bypass: caller may revoke their own session even without auth.session.revoke."` +}