diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json index 43f41bc..43e430b 100644 --- a/.agents/plugins/marketplace.json +++ b/.agents/plugins/marketplace.json @@ -6,7 +6,7 @@ "plugins": [ { "name": "simulator", - "version": "2.4.2", + "version": "2.4.3", "source": { "source": "local", "path": "./plugins/simulator" diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1b4452b..f63ae8d 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "simulator", "source": "./plugins/simulator", "description": "Simulator.Company platform assistant. Exposes the Simulator REST API as an MCP server and provides skills for managing actors, forms, graph structures, and financial accounts.", - "version": "2.4.2", + "version": "2.4.3", "author": { "name": "Simulator.Company", "url": "https://simulator.company" diff --git a/.gitignore b/.gitignore index d49606c..442d935 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ bin/ # Redis snapshot left behind when running a local Redis from the repo dir. dump.rdb /plugins/simulator/mcp-server/.env +/plugins/simulator/mcp-server/.env.bak .claude/settings.local.json # Stray Go binaries built into the module dir (e.g. `go build ./cmd/`). diff --git a/CHANGELOG.md b/CHANGELOG.md index db088c2..6063257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [2.4.3] + +### Added +- `logout` tool: removes `ACCESS_TOKEN` from `.env` (with a `.env.bak` backup first) and warns that the file may be shared with sibling plugins +- `status` tool: self-diagnosis — environment, token state, `.env` path; `probe=true` verifies `ACCOUNT_URL` against the gateway's public config (cloud and on-prem) + +### Changed +- OAuth hardening per OAuth 2.1 / RFC 8414 / RFC 8252: endpoints resolved from the account service's `/.well-known/oauth-authorization-server` metadata (fallback to the conventional paths for older on-prem installs); silent token renewal via the `refresh_token` grant before any browser round-trip — the refreshed token is trusted only after one authenticated papi call succeeds, otherwise the flow falls back to the browser (the refresh token is stored in `.env`, removed by `logout` and included in its backup); token-endpoint responses are never echoed into error messages (only field names); `ACCOUNT_URL` scheme validation (https, or http for localhost only) before the browser opens; standard `access_token` accepted alongside `simulator_token`; the token request is bounded by a timeout +- `login` self-heals a wrong `ACCOUNT_URL` before opening the browser: it asks the chosen gateway's public config for the real account service (`saUrl`) and corrects `.env` when they disagree, reporting what was fixed +- OAuth login failures now explain what to check and how to fix it, and show which consent URL was used (diagnosis — the flow must be re-run, its callback listener is closed by then); the wait and the token exchange honour client cancellation instead of blocking for the full timeout + ## [2.4.2] ### Fixed diff --git a/POWER.md b/POWER.md index 443437e..e000a1a 100644 --- a/POWER.md +++ b/POWER.md @@ -1,7 +1,7 @@ --- name: simulator displayName: Simulator.Company -version: 2.4.2 +version: 2.4.3 description: BPM, graph, and financial-tracking toolkit for the Simulator.Company platform. Exposes the Simulator REST API as MCP tools plus 16 skills covering actors, forms, graphs, layers, accounts, transactions, transfers, charts, smart forms, meetings, reactions, and attachments. author: name: Simulator.Company diff --git a/README.md b/README.md index 2b266d9..345f136 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ the actor/node items.) | Meetings | `getTranscription` (read a meeting call's speech transcription — summarize / extract action items; needs a live room) | | Smart Forms (runtime) | `appGetPage` `appSendForm` (drive any Smart Form / CDU app via the `get`/`send` page protocol — render a page, submit a form; Corezoid supplies data & control flow. Universal primitives for the `simulator-smart-forms-runtime` skill) | | Users | `getUsers` `getUser` `searchUsers` (workspace members — resolve a userId/groupId for sharing) | -| Setup | `set-environment` (cloud preset or custom/local URL) `login` `getWorkspaces` `set-workspace` (by accId or name) | +| Setup | `set-environment` (cloud preset or custom/local URL) `login` (verifies/self-heals `ACCOUNT_URL` against the gateway config before opening the browser) `logout` (removes the token from `.env`, backing it up to `.env.bak` first) `status` (self-diagnosis: environment, token state, `.env` path; `probe=true` also checks `ACCOUNT_URL` against the gateway) `getWorkspaces` `set-workspace` (by accId or name) | **Engine tools** (multi-call workflows + client-side computation): diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d39e30b..301ed64 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -255,7 +255,7 @@ backend operation, with typed parameters: | Meetings | `getTranscription` (read a meeting call's speech transcription — summarize / extract action items; needs a live room) | | Smart Forms (runtime) | `appGetPage` `appSendForm` (`smartforms.go`): drive any Smart Form / CDU app via the `get`/`send` page protocol (render a page → submit a form). The env's Corezoid process supplies runtime data, validation and control flow; the tools carry no app-specific logic, so the same two drive every mini-app. Runtime counterpart to the authoring surface (`createSmartForm` / `app_content`). See [`smart-forms.md`](../plugins/simulator/docs/user-flows/smart-forms.md) / [`cdu-page-protocol.md`](../plugins/simulator/docs/user-flows/cdu-page-protocol.md). | | Users | `getUsers` `getUser` `searchUsers` (workspace members — resolve a userId/groupId for sharing) | -| Setup | `set-environment` (cloud preset or custom/local URL; derives the account URL from the gateway's public config) `login` `getWorkspaces` `set-workspace` (by accId or name) | +| Setup | `set-environment` (cloud preset or custom/local URL; derives the account URL from the gateway's public config) `login` `logout` (remove stored credentials; `.env` backed up to `.env.bak`) `status` (no-side-effect diagnosis: connectivity, token validity, account/API URLs) `getWorkspaces` `set-workspace` (by accId or name) | | Web links | `buildLink` (local, no HTTP — `deeplinks.go`): build an absolute web-app deep-link for an entity (actor / event / chat / layer / transaction / …). Mirrors the web routes published at `/routes.json`; derives the web base by dropping `/papi/1.0` from the API base and defaults `acc` to the active workspace. Workspace mode only (hidden by `ActorToolFilter` in actor sessions). | | Skill registry | `findSkill` `getSkill` (local composite — `skills.go`, registered outside `allOps()` so the drift gate skips them, like `buildLink`/`readAttachment`): the data-driven skill registry. A skill is an actor of the `Skills` system form; `findSkill` discovers published (`verified`) skills by intent via `filterActors` (empty query lists all), `getSkill` loads one in full (including the `description` body) by `ref`/slug (`getActorByRef`) or `id`. Both resolve the `Skills` form id by listing system forms and matching the title (cached per workspace). See [`ai-skills.md`](../plugins/simulator/docs/entities/ai-skills.md) and the `/simulator-skills` skill. | | Actor agents | `findAgent` `getAgent` (local composite — `agents.go`, registered outside `allOps()`, drift gate skips them): the actor-analog of the skill registry. Any actor whose `description` is an "# Agent" profile is an agent — the common case is a user twin (`systemObjType="user"` on the `System` form), but a non-user system actor or a plain business actor (team/service/org/process) can be one too. `findAgent` discovers agents by competency over their profiles via `searchAll` — scoped to the `System` form by default, or to another agent-registry form via `formId` (it is always form-scoped, never an unscoped workspace search; empty query enumerates the registry: members via `getUsers` for the default, `filterActors` for a `formId`). `getAgent` loads one profile in full by `userId` (person twin, get-or-create via the system-actor endpoint) or `actorId` (any agent actor). Resolves the `System` form id like `skills.go` (cached per workspace). See [`digital-twin-agents.md`](../plugins/simulator/docs/entities/digital-twin-agents.md) and the `/simulator-agents` skill. | diff --git a/plugins/simulator/.claude-plugin/plugin.json b/plugins/simulator/.claude-plugin/plugin.json index 5da4674..a5f2480 100644 --- a/plugins/simulator/.claude-plugin/plugin.json +++ b/plugins/simulator/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "simulator", - "version": "2.4.2", + "version": "2.4.3", "description": "Simulator.Company platform assistant. Exposes the Simulator REST API as an MCP server and provides skills for managing actors, forms, graph structures, and financial accounts.", "author": { "name": "Simulator.Company", diff --git a/plugins/simulator/.codex-plugin/plugin.json b/plugins/simulator/.codex-plugin/plugin.json index 5da4674..a5f2480 100644 --- a/plugins/simulator/.codex-plugin/plugin.json +++ b/plugins/simulator/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "simulator", - "version": "2.4.2", + "version": "2.4.3", "description": "Simulator.Company platform assistant. Exposes the Simulator REST API as an MCP server and provides skills for managing actors, forms, graph structures, and financial accounts.", "author": { "name": "Simulator.Company", diff --git a/plugins/simulator/.kiro-plugin/plugin.json b/plugins/simulator/.kiro-plugin/plugin.json index fd7d049..4975159 100644 --- a/plugins/simulator/.kiro-plugin/plugin.json +++ b/plugins/simulator/.kiro-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "simulator", - "version": "2.4.2", + "version": "2.4.3", "description": "Simulator.Company platform assistant. Exposes the Simulator REST API as an MCP server and provides skills for managing actors, forms, graph structures, and financial accounts.", "author": { "name": "Simulator.Company", diff --git a/plugins/simulator/mcp-server/app/auth/credentials.go b/plugins/simulator/mcp-server/app/auth/credentials.go index d76200a..18ec8e3 100644 --- a/plugins/simulator/mcp-server/app/auth/credentials.go +++ b/plugins/simulator/mcp-server/app/auth/credentials.go @@ -16,9 +16,10 @@ var envMu sync.Mutex // Credentials holds the Simulator JWT token. type Credentials struct { - AccessToken string `json:"access_token"` - ExpiresAt time.Time `json:"expires_at"` - TokenType string `json:"token_type"` // always "Simulator" + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt time.Time `json:"expires_at"` + TokenType string `json:"token_type"` // always "Simulator" } // AuthorizationHeader returns the value to use for the Authorization header. @@ -114,8 +115,9 @@ func Load() (*Credentials, error) { return nil, nil } creds := &Credentials{ - AccessToken: token, - TokenType: "Simulator", + AccessToken: token, + RefreshToken: os.Getenv("REFRESH_TOKEN"), + TokenType: "Simulator", } if expiryStr := os.Getenv("ACCESS_TOKEN_EXPIRES_AT"); expiryStr != "" { if t, err := time.Parse(time.RFC3339, expiryStr); err == nil { @@ -137,6 +139,11 @@ func Save(creds *Credentials) error { expStr = creds.ExpiresAt.Format(time.RFC3339) kv = append(kv, [2]string{"ACCESS_TOKEN_EXPIRES_AT", expStr}) } + // Only overwrite the stored refresh token when the server actually issued + // one — some grants return an access token alone. + if creds.RefreshToken != "" { + kv = append(kv, [2]string{"REFRESH_TOKEN", creds.RefreshToken}) + } if err := updateEnvFileMulti(envFilePath(), kv); err != nil { return fmt.Errorf("failed to save token to .env: %w", err) } @@ -144,6 +151,9 @@ func Save(creds *Credentials) error { if expStr != "" { os.Setenv("ACCESS_TOKEN_EXPIRES_AT", expStr) } + if creds.RefreshToken != "" { + os.Setenv("REFRESH_TOKEN", creds.RefreshToken) + } return nil } @@ -159,8 +169,12 @@ func Delete() error { if err := removeEnvKey(path, "ACCESS_TOKEN_EXPIRES_AT"); err != nil { return err } + if err := removeEnvKey(path, "REFRESH_TOKEN"); err != nil { + return err + } os.Unsetenv("ACCESS_TOKEN") os.Unsetenv("ACCESS_TOKEN_EXPIRES_AT") + os.Unsetenv("REFRESH_TOKEN") return nil } @@ -219,6 +233,77 @@ func SaveWorkspaceID(accID string) error { return nil } +// EnvPath reports which .env file the auth helpers read and write, so tools +// can name the exact file in user-facing messages. +func EnvPath() string { return envFilePath() } + +// BackupToken appends the current ACCESS_TOKEN / ACCESS_TOKEN_EXPIRES_AT lines +// from .env to .env.bak before a logout removes them: the .env is shared with +// sibling plugins (e.g. corezoid), so an accidental logout must stay +// recoverable by hand. Returns the backup path, or "" if there was nothing to +// back up. +func BackupToken() (string, error) { + envMu.Lock() + defer envMu.Unlock() + path := envFilePath() + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", err + } + var saved []string + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "ACCESS_TOKEN=") || strings.HasPrefix(line, "ACCESS_TOKEN_EXPIRES_AT=") || strings.HasPrefix(line, "REFRESH_TOKEN=") { + saved = append(saved, line) + } + } + if len(saved) == 0 { + return "", nil + } + bakPath := path + ".bak" + f, err := os.OpenFile(bakPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return "", err + } + // 0600 above only applies at creation — tighten a pre-existing backup too. + _ = f.Chmod(0600) + defer f.Close() + stamp := time.Now().UTC().Format(time.RFC3339) + if _, err := fmt.Fprintf(f, "# saved by simulator logout at %s\n%s\n", stamp, strings.Join(saved, "\n")); err != nil { + return "", err + } + return bakPath, nil +} + +// SaveRefreshToken persists only the refresh token — used when the account +// service rotates it on a refresh attempt whose access token was rejected, so +// a rotating AS cannot burn the stored token. +func SaveRefreshToken(rt string) error { + envMu.Lock() + defer envMu.Unlock() + if err := updateEnvFile(envFilePath(), "REFRESH_TOKEN", rt); err != nil { + return err + } + os.Setenv("REFRESH_TOKEN", rt) + return nil +} + +// DeleteRefreshToken removes only the refresh token — used when the account +// service's refresh grant returned a token the API rejects, so re-trying it on +// every login would just add round-trips (a fresh browser login stores a new +// refresh token anyway). +func DeleteRefreshToken() error { + envMu.Lock() + defer envMu.Unlock() + if err := removeEnvKey(envFilePath(), "REFRESH_TOKEN"); err != nil { + return err + } + os.Unsetenv("REFRESH_TOKEN") + return nil +} + // IsExpired reports whether the credentials are expired. func IsExpired(creds *Credentials) bool { if creds == nil || creds.AccessToken == "" { diff --git a/plugins/simulator/mcp-server/app/auth/lifecycle_test.go b/plugins/simulator/mcp-server/app/auth/lifecycle_test.go new file mode 100644 index 0000000..ef3e8de --- /dev/null +++ b/plugins/simulator/mcp-server/app/auth/lifecycle_test.go @@ -0,0 +1,115 @@ +package auth + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPKCEFlow_HonorsCtxCancel(t *testing.T) { + orig := openBrowserFn + openBrowserFn = func(string) error { return nil } + defer func() { openBrowserFn = orig }() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, authURL, err := PKCEFlow(ctx, "https://account.example.com", "", nil) + if err == nil || !strings.Contains(err.Error(), "cancelled by the client") { + t.Fatalf("want cancellation error, got %v", err) + } + if !strings.Contains(authURL, "https://account.example.com/oauth2/authorize?") { + t.Fatalf("authURL must be returned for a manual fallback, got %q", authURL) + } +} + +func TestBackupToken_SavesRemovedLines(t *testing.T) { + dir := t.TempDir() + t.Setenv("SIMULATOR_WORK_DIR", dir) + envPath := filepath.Join(dir, ".env") + content := "SIMULATOR_API_BASE_URL=https://gw.example.com/papi/1.0\nACCESS_TOKEN=tok123\nACCESS_TOKEN_EXPIRES_AT=2026-12-01T00:00:00Z\n" + if err := os.WriteFile(envPath, []byte(content), 0600); err != nil { + t.Fatal(err) + } + + bak, err := BackupToken() + if err != nil { + t.Fatal(err) + } + if bak != envPath+".bak" { + t.Fatalf("unexpected backup path %q", bak) + } + data, err := os.ReadFile(bak) + if err != nil { + t.Fatal(err) + } + s := string(data) + if !strings.Contains(s, "ACCESS_TOKEN=tok123") || !strings.Contains(s, "ACCESS_TOKEN_EXPIRES_AT=2026-12-01T00:00:00Z") { + t.Fatalf("backup missing token lines: %s", s) + } + if !strings.Contains(s, "# saved by simulator logout") { + t.Fatalf("backup missing annotation: %s", s) + } + if strings.Contains(s, "SIMULATOR_API_BASE_URL") { + t.Fatalf("backup must contain only the token lines: %s", s) + } +} + +func TestBackupToken_NothingToBackup(t *testing.T) { + dir := t.TempDir() + t.Setenv("SIMULATOR_WORK_DIR", dir) + if err := os.WriteFile(filepath.Join(dir, ".env"), []byte("WORKSPACE_ID=1\n"), 0600); err != nil { + t.Fatal(err) + } + + bak, err := BackupToken() + if err != nil { + t.Fatal(err) + } + if bak != "" { + t.Fatalf("want empty path when there is nothing to back up, got %q", bak) + } + if _, err := os.Stat(filepath.Join(dir, ".env.bak")); !os.IsNotExist(err) { + t.Fatalf(".env.bak must not be created when there is nothing to back up") + } +} + +func TestBackupToken_MissingEnvFile(t *testing.T) { + t.Setenv("SIMULATOR_WORK_DIR", t.TempDir()) + bak, err := BackupToken() + if err != nil || bak != "" { + t.Fatalf("missing .env must be a no-op, got path=%q err=%v", bak, err) + } +} + +func TestSaveDeleteRefreshTokenRoundtrip(t *testing.T) { + dir := t.TempDir() + t.Setenv("SIMULATOR_WORK_DIR", dir) + t.Setenv("REFRESH_TOKEN", "") + os.Unsetenv("REFRESH_TOKEN") + + if err := Save(&Credentials{AccessToken: "at-1", RefreshToken: "rt-1", TokenType: "Simulator"}); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(filepath.Join(dir, ".env")) + if !strings.Contains(string(data), "REFRESH_TOKEN=rt-1") { + t.Fatalf("refresh token not persisted: %s", data) + } + // A grant without a refresh token must not clobber the stored one. + if err := Save(&Credentials{AccessToken: "at-2", TokenType: "Simulator"}); err != nil { + t.Fatal(err) + } + data, _ = os.ReadFile(filepath.Join(dir, ".env")) + if !strings.Contains(string(data), "REFRESH_TOKEN=rt-1") { + t.Fatalf("token-only save must keep the refresh token: %s", data) + } + if err := Delete(); err != nil { + t.Fatal(err) + } + data, _ = os.ReadFile(filepath.Join(dir, ".env")) + if strings.Contains(string(data), "REFRESH_TOKEN") || os.Getenv("REFRESH_TOKEN") != "" { + t.Fatalf("logout must remove the refresh token: %s", data) + } +} diff --git a/plugins/simulator/mcp-server/app/auth/oauth.go b/plugins/simulator/mcp-server/app/auth/oauth.go index 05b063c..874520f 100644 --- a/plugins/simulator/mcp-server/app/auth/oauth.go +++ b/plugins/simulator/mcp-server/app/auth/oauth.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "crypto/sha256" + "crypto/tls" "encoding/base64" "encoding/json" "fmt" @@ -15,6 +16,7 @@ import ( "net/url" "os" "os/exec" + "sort" "strings" "time" ) @@ -26,12 +28,45 @@ const ( DefaultClientID = "5ec679f5a2710f0da6000005" ) +// InsecureTLS mirrors the server's --insecure mode for the auth endpoints +// (discovery + token exchange). Set once at startup; without it a self-signed +// on-prem account service passes config probes but dies on the token POST. +var InsecureTLS bool + +// NewHTTPClient returns an HTTP client whose transport honours the insecure +// (skip-TLS-verify) flag. It is the single place --insecure transport behaviour +// lives, shared by the OAuth endpoints and the papi token probe in package +// tools; change proxy/retry/cipher policy here and both callers follow. +func NewHTTPClient(insecure bool, timeout time.Duration) *http.Client { + tr := &http.Transport{} + if insecure { + tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec G402 — opt-in via --insecure + } + return &http.Client{Timeout: timeout, Transport: tr} +} + +// authHTTPClient returns a client honouring the package-level InsecureTLS flag. +func authHTTPClient(timeout time.Duration) *http.Client { + return NewHTTPClient(InsecureTLS, timeout) +} + // PKCEFlow runs the full OAuth2 PKCE authorization code flow. // It starts a local HTTP server to receive the callback, opens the user's browser, // waits for the authorization code, exchanges it for tokens, and returns Credentials. // accountURL defaults to DefaultAccountURL if empty (also checks ACCOUNT_URL env var). // clientID defaults to DefaultClientID if empty. -func PKCEFlow(accountURL, clientID string, scopes []string) (*Credentials, error) { +// +// The authorization URL is returned alongside the credentials (also on failure, +// once it has been built) so callers can show WHICH consent URL was used — +// useful to diagnose a wrong account host. Note it cannot complete a login +// after the flow has failed: the loopback listener is already closed by then, +// so callers must tell the user to re-run login, not to open the URL for a +// retry. The wait for the browser callback honours ctx: cancelling the tool +// call stops the flow instead of leaving it blocked for the full timeout. +func PKCEFlow(ctx context.Context, accountURL, clientID string, scopes []string) (*Credentials, string, error) { + if ctx == nil { + ctx = context.Background() + } if accountURL == "" { accountURL = os.Getenv("ACCOUNT_URL") } @@ -39,18 +74,20 @@ func PKCEFlow(accountURL, clientID string, scopes []string) (*Credentials, error accountURL = DefaultAccountURL } accountURL = strings.TrimRight(accountURL, "/") + if err := validateAccountURLScheme(accountURL); err != nil { + return nil, "", err + } if clientID == "" { clientID = DefaultClientID } - authorizeURL := accountURL + "/oauth2/authorize" - tokenURL := accountURL + "/oauth2/token" + authorizeURL, tokenURL := discoverOAuthEndpoints(ctx, accountURL) // Generate PKCE code verifier (random 32 bytes → base64url, no padding) verifierBytes := make([]byte, 32) if _, err := rand.Read(verifierBytes); err != nil { - return nil, fmt.Errorf("failed to generate PKCE verifier: %w", err) + return nil, "", fmt.Errorf("failed to generate PKCE verifier: %w", err) } codeVerifier := base64.RawURLEncoding.EncodeToString(verifierBytes) @@ -63,14 +100,14 @@ func PKCEFlow(accountURL, clientID string, scopes []string) (*Credentials, error // code injection). We reject any callback whose state does not match. stateBytes := make([]byte, 32) if _, err := rand.Read(stateBytes); err != nil { - return nil, fmt.Errorf("failed to generate OAuth state: %w", err) + return nil, "", fmt.Errorf("failed to generate OAuth state: %w", err) } state := base64.RawURLEncoding.EncodeToString(stateBytes) // Pick a random available port for the redirect server listener, err := net.Listen("tcp", "localhost:0") if err != nil { - return nil, fmt.Errorf("failed to start callback listener: %w", err) + return nil, "", fmt.Errorf("failed to start callback listener: %w", err) } port := listener.Addr().(*net.TCPAddr).Port redirectURI := fmt.Sprintf("http://localhost:%d/callback", port) @@ -142,25 +179,29 @@ func PKCEFlow(accountURL, clientID string, scopes []string) (*Credentials, error }() log.Printf("Opening browser for Simulator authorization...\nIf it did not open automatically, visit:\n %s\n", authURL) - _ = openBrowser(authURL) + _ = openBrowserFn(authURL) var code string select { case code = <-codeCh: case oauthErr := <-errCh: _ = srv.Shutdown(context.Background()) - return nil, oauthErr + return nil, authURL, oauthErr + case <-ctx.Done(): + _ = srv.Shutdown(context.Background()) + return nil, authURL, fmt.Errorf("authentication wait cancelled by the client: %w", ctx.Err()) case <-time.After(5 * time.Minute): _ = srv.Shutdown(context.Background()) - return nil, fmt.Errorf("timed out waiting for OAuth callback (5 minutes)") + return nil, authURL, fmt.Errorf("timed out waiting for OAuth callback (5 minutes) — the consent in the browser was never completed") } _ = srv.Shutdown(context.Background()) - return exchangeCode(tokenURL, clientID, code, codeVerifier, redirectURI) + creds, err := exchangeCode(ctx, tokenURL, clientID, code, codeVerifier, redirectURI) + return creds, authURL, err } // exchangeCode exchanges an authorization code for access and refresh tokens. -func exchangeCode(tokenURL, clientID, code, codeVerifier, redirectURI string) (*Credentials, error) { +func exchangeCode(ctx context.Context, tokenURL, clientID, code, codeVerifier, redirectURI string) (*Credentials, error) { data := url.Values{} data.Set("grant_type", "authorization_code") data.Set("client_id", clientID) @@ -168,18 +209,130 @@ func exchangeCode(tokenURL, clientID, code, codeVerifier, redirectURI string) (* data.Set("code_verifier", codeVerifier) data.Set("redirect_uri", redirectURI) - return postTokenRequest(tokenURL, data) + return postTokenRequest(ctx, tokenURL, data) +} + +// Refresh exchanges a refresh token for a fresh access token +// (grant_type=refresh_token) — the OAuth 2.1 silent-renewal path, so an +// expired session does not force a browser round-trip. The account service +// may rotate the refresh token; the new one is returned in the credentials. +func Refresh(ctx context.Context, accountURL, clientID, refreshToken string) (*Credentials, error) { + accountURL = strings.TrimRight(accountURL, "/") + if accountURL == "" { + accountURL = DefaultAccountURL + } + if err := validateAccountURLScheme(accountURL); err != nil { + return nil, err + } + if clientID == "" { + clientID = DefaultClientID + } + _, tokenURL := discoverOAuthEndpoints(ctx, accountURL) + data := url.Values{} + data.Set("grant_type", "refresh_token") + data.Set("client_id", clientID) + data.Set("refresh_token", refreshToken) + return postTokenRequest(ctx, tokenURL, data) +} + +// validateAccountURLScheme enforces the OAuth 2.1 rule that authorization +// server endpoints are HTTPS; plain http is allowed only for loopback hosts +// (local/on-prem development). Catching this before the browser opens turns a +// confusing consent-page failure into an actionable message. +func validateAccountURLScheme(accountURL string) error { + u, err := url.Parse(accountURL) + if err != nil { + return fmt.Errorf("ACCOUNT_URL %q is not a valid URL: %w", accountURL, err) + } + switch u.Scheme { + case "https": + return nil + case "http": + h := u.Hostname() + if h == "localhost" || h == "127.0.0.1" || h == "::1" { + return nil + } + return fmt.Errorf("ACCOUNT_URL %q uses plain http to a non-local host — the OAuth flow would send secrets unencrypted; use https", accountURL) + default: + return fmt.Errorf("ACCOUNT_URL %q must start with https:// (or http:// for localhost only)", accountURL) + } +} + +// discoverOAuthEndpoints resolves the authorization and token endpoints via +// RFC 8414 metadata ({accountURL}/.well-known/oauth-authorization-server) and +// falls back to the conventional /oauth2/authorize + /oauth2/token paths when +// the metadata is unavailable (older on-prem account services). +func discoverOAuthEndpoints(ctx context.Context, accountURL string) (authorizeURL, tokenURL string) { + authorizeURL = accountURL + "/oauth2/authorize" + tokenURL = accountURL + "/oauth2/token" + if ctx == nil { + ctx = context.Background() + } + mdCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(mdCtx, http.MethodGet, accountURL+"/.well-known/oauth-authorization-server", nil) + if err != nil { + return authorizeURL, tokenURL + } + resp, err := authHTTPClient(5 * time.Second).Do(req) + if err != nil { + log.Printf("oauth discovery: metadata fetch failed (%v) — using conventional endpoints", err) + return authorizeURL, tokenURL + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return authorizeURL, tokenURL + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return authorizeURL, tokenURL + } + var meta struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + } + if err := json.Unmarshal(body, &meta); err != nil || meta.AuthorizationEndpoint == "" || meta.TokenEndpoint == "" { + return authorizeURL, tokenURL + } + if validateAccountURLScheme(meta.AuthorizationEndpoint) != nil || validateAccountURLScheme(meta.TokenEndpoint) != nil { + log.Printf("oauth discovery: metadata endpoints have an unacceptable scheme — using conventional endpoints") + return authorizeURL, tokenURL + } + return meta.AuthorizationEndpoint, meta.TokenEndpoint } // tokenResponse is the raw JSON response from the Simulator token endpoint. +// The service returns the JWT as simulator_token; the standard access_token +// field (RFC 6749 §5.1) is accepted as a fallback for standards-shaped +// responses. refresh_token, when present, enables silent renewal. type tokenResponse struct { - SimulatorToken string `json:"simulator_token"` // JWT — the actual MCP auth token - Error string `json:"error"` - ErrorDesc string `json:"error_description"` + SimulatorToken string `json:"simulator_token"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + // The refresh grant answers with its own field names (verified live). + NewAccessToken string `json:"new_access_token"` + NewAccessTokenExpire float64 `json:"new_access_token_expire"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` + // The account service reports failures in its own envelope + // ({"result":"error","message":"..."}) rather than the RFC 6749 shape. + Result string `json:"result"` + Message string `json:"message"` } -func postTokenRequest(tokenURL string, data url.Values) (*Credentials, error) { - resp, err := http.PostForm(tokenURL, data) +func postTokenRequest(ctx context.Context, tokenURL string, data url.Values) (*Credentials, error) { + if ctx == nil { + ctx = context.Background() + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to build token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + // A bounded, ctx-aware client honouring the --insecure TLS mode: the + // default http.Client has no timeout and a stuck token endpoint would + // hang the login tool call forever. + resp, err := authHTTPClient(60 * time.Second).Do(req) if err != nil { return nil, fmt.Errorf("token request failed: %w", err) } @@ -198,22 +351,46 @@ func postTokenRequest(tokenURL string, data url.Values) (*Credentials, error) { if tr.Error != "" { return nil, fmt.Errorf("token error: %s – %s", tr.Error, tr.ErrorDesc) } - if tr.SimulatorToken == "" { - return nil, fmt.Errorf("no simulator_token in response: %s", string(body)) + if tr.Result == "error" { + return nil, fmt.Errorf("token error: %s", tr.Message) + } + token := tr.SimulatorToken + if token == "" { + token = tr.AccessToken + } + if token == "" { + token = tr.NewAccessToken + } + if token == "" { + // Do NOT include the body: a response we merely failed to recognize + // may still carry a live token, and error strings end up in logs. + var fields map[string]any + _ = json.Unmarshal(body, &fields) + keys := make([]string, 0, len(fields)) + for k := range fields { + keys = append(keys, k) + } + sort.Strings(keys) + return nil, fmt.Errorf("no simulator_token in response (fields: %s)", strings.Join(keys, ", ")) } // If the token carries no usable exp claim, fall back to a conservative // window rather than "never expires" (see jwtExpiry / IsExpired): that way // a malformed/exp-less token is re-validated periodically instead of being // trusted forever. - expiry := jwtExpiry(tr.SimulatorToken) + expiry := jwtExpiry(token) + if expiry.IsZero() && tr.NewAccessTokenExpire > 0 { + // Opaque (non-JWT) tokens carry their expiry as a unix-seconds field. + expiry = time.Unix(int64(tr.NewAccessTokenExpire), 0) + } if expiry.IsZero() { expiry = time.Now().Add(12 * time.Hour) } creds := &Credentials{ - AccessToken: tr.SimulatorToken, - TokenType: "Simulator", - ExpiresAt: expiry, + AccessToken: token, + RefreshToken: tr.RefreshToken, + TokenType: "Simulator", + ExpiresAt: expiry, } return creds, nil } @@ -308,6 +485,9 @@ func oauthPageHTML(title, kind, heading, detail, action string) string { "" } +// openBrowserFn is a seam so tests can intercept the browser launch. +var openBrowserFn = openBrowser + // openBrowser opens the given URL in the default system browser (macOS / Linux / Windows). func openBrowser(u string) error { if err := exec.Command("open", u).Start(); err == nil { diff --git a/plugins/simulator/mcp-server/app/auth/oauth_bestpractices_test.go b/plugins/simulator/mcp-server/app/auth/oauth_bestpractices_test.go new file mode 100644 index 0000000..a6332b5 --- /dev/null +++ b/plugins/simulator/mcp-server/app/auth/oauth_bestpractices_test.go @@ -0,0 +1,163 @@ +package auth + +import ( + "context" + "encoding/base64" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" +) + +func testJWTWithExp(t *testing.T, exp int64) string { + t.Helper() + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"exp":` + strconv.FormatInt(exp, 10) + `}`)) + return header + "." + payload + ".sig" +} + +func TestValidateAccountURLScheme(t *testing.T) { + cases := []struct { + url string + ok bool + }{ + {"https://account.corezoid.com", true}, + {"http://localhost:9000", true}, + {"http://127.0.0.1:9000", true}, + {"http://account.onprem.example", false}, + {"ftp://account.corezoid.com", false}, + } + for _, c := range cases { + if err := validateAccountURLScheme(c.url); (err == nil) != c.ok { + t.Errorf("validateAccountURLScheme(%q): err=%v, want ok=%v", c.url, err, c.ok) + } + } +} + +func TestDiscoverOAuthEndpoints(t *testing.T) { + var srvURL string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/.well-known/oauth-authorization-server" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"authorization_endpoint":"` + srvURL + `/a","token_endpoint":"` + srvURL + `/t"}`)) + })) + defer srv.Close() + srvURL = srv.URL + + authz, token := discoverOAuthEndpoints(context.Background(), srv.URL) + if authz != srv.URL+"/a" || token != srv.URL+"/t" { + t.Errorf("metadata not used: %q %q", authz, token) + } + + bad := httptest.NewServer(http.NotFoundHandler()) + defer bad.Close() + authz, token = discoverOAuthEndpoints(context.Background(), bad.URL) + if authz != bad.URL+"/oauth2/authorize" || token != bad.URL+"/oauth2/token" { + t.Errorf("want conventional fallback, got %q %q", authz, token) + } +} + +func TestRefresh_ExchangesAndRotates(t *testing.T) { + tok := testJWTWithExp(t, 4102444800) // 2100-01-01 + var gotGrant, gotRefresh string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/oauth2/token" { + http.NotFound(w, r) + return + } + _ = r.ParseForm() + gotGrant = r.Form.Get("grant_type") + gotRefresh = r.Form.Get("refresh_token") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"simulator_token":"` + tok + `","refresh_token":"rt-rotated"}`)) + })) + defer srv.Close() + + creds, err := Refresh(context.Background(), srv.URL, "client-1", "rt-old") + if err != nil { + t.Fatal(err) + } + if gotGrant != "refresh_token" || gotRefresh != "rt-old" { + t.Errorf("wrong grant sent: %q %q", gotGrant, gotRefresh) + } + if creds.AccessToken != tok || creds.RefreshToken != "rt-rotated" { + t.Errorf("rotated pair not captured: %+v", creds) + } +} + +func TestPostTokenRequest_StandardAccessTokenFallback(t *testing.T) { + tok := testJWTWithExp(t, 4102444800) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"` + tok + `"}`)) + })) + defer srv.Close() + + creds, err := postTokenRequest(context.Background(), srv.URL, url.Values{"grant_type": {"authorization_code"}}) + if err != nil { + t.Fatal(err) + } + if creds.AccessToken != tok { + t.Errorf("standard access_token field must be accepted: %+v", creds) + } +} + +func TestPKCEFlow_RejectsInsecureAccountURL(t *testing.T) { + orig := openBrowserFn + opened := false + openBrowserFn = func(string) error { opened = true; return nil } + defer func() { openBrowserFn = orig }() + + _, _, err := PKCEFlow(context.Background(), "http://account.onprem.example", "", nil) + if err == nil || !strings.Contains(err.Error(), "unencrypted") { + t.Fatalf("plain-http non-local account URL must be rejected, got %v", err) + } + if opened { + t.Errorf("the browser must not open for a rejected account URL") + } +} + +func TestPostTokenRequest_ParsesServiceErrorEnvelope(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"request_id":"x","result":"error","message":"Wrong refresh token"}`)) + })) + defer srv.Close() + _, err := postTokenRequest(context.Background(), srv.URL, url.Values{"grant_type": {"refresh_token"}}) + if err == nil || !strings.Contains(err.Error(), "Wrong refresh token") { + t.Fatalf("service error envelope must surface its message, got %v", err) + } +} + +func TestPostTokenRequest_ParsesRefreshGrantShapeAndRedacts(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"request_id":"x","result":"ok","new_access_token":"atn_abc","new_access_token_expire":1784061715}`)) + })) + defer srv.Close() + creds, err := postTokenRequest(context.Background(), srv.URL, url.Values{"grant_type": {"refresh_token"}}) + if err != nil { + t.Fatal(err) + } + if creds.AccessToken != "atn_abc" || creds.ExpiresAt.Unix() != 1784061715 { + t.Errorf("refresh grant shape not parsed: %+v", creds) + } + + leak := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"result":"ok","mystery_field":"secret-value-42"}`)) + })) + defer leak.Close() + _, err = postTokenRequest(context.Background(), leak.URL, url.Values{}) + if err == nil || strings.Contains(err.Error(), "secret-value-42") { + t.Fatalf("error must not leak response values: %v", err) + } + if !strings.Contains(err.Error(), "mystery_field") { + t.Fatalf("error should name the fields seen: %v", err) + } +} diff --git a/plugins/simulator/mcp-server/app/auth/testmain_test.go b/plugins/simulator/mcp-server/app/auth/testmain_test.go new file mode 100644 index 0000000..85b5f61 --- /dev/null +++ b/plugins/simulator/mcp-server/app/auth/testmain_test.go @@ -0,0 +1,24 @@ +package auth + +import ( + "os" + "testing" +) + +// TestMain pins SIMULATOR_WORK_DIR to a throwaway directory for the whole +// package: the credential helpers write to {SIMULATOR_WORK_DIR|cwd}/.env, and +// a test that forgets t.Setenv must corrupt a sandbox, not a real .env. +func TestMain(m *testing.M) { + tmp, err := os.MkdirTemp("", "sim-auth-test-*") + if err == nil { + os.Setenv("SIMULATOR_WORK_DIR", tmp) + } + os.Unsetenv("ACCESS_TOKEN") + os.Unsetenv("ACCESS_TOKEN_EXPIRES_AT") + os.Unsetenv("REFRESH_TOKEN") + code := m.Run() + if tmp != "" { + os.RemoveAll(tmp) + } + os.Exit(code) +} diff --git a/plugins/simulator/mcp-server/internal/tools/auth_selfheal_test.go b/plugins/simulator/mcp-server/internal/tools/auth_selfheal_test.go new file mode 100644 index 0000000..212469c --- /dev/null +++ b/plugins/simulator/mcp-server/internal/tools/auth_selfheal_test.go @@ -0,0 +1,137 @@ +package tools + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// fakeGateway serves the public config endpoint ({base}/config) declaring the +// given saUrl — the shape login's self-heal reads via FetchPublicConfig. +func fakeGateway(t *testing.T, saURL string) *httptest.Server { + t.Helper() + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/config" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"saUrl":"` + saURL + `"}}`)) + })) + t.Cleanup(ts.Close) + return ts +} + +func setupEnvFile(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + t.Setenv("SIMULATOR_WORK_DIR", dir) + envPath := filepath.Join(dir, ".env") + if content != "" { + if err := os.WriteFile(envPath, []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + return envPath +} + +func TestResolveAccountURL_HealsPoisonedEnv(t *testing.T) { + sa := "https://account.example.com" + gw := fakeGateway(t, sa) + envPath := setupEnvFile(t, "ACCOUNT_URL=https://admin.corezoid.com\n") + t.Setenv("ACCOUNT_URL", "https://admin.corezoid.com") + + got, note := resolveAccountURL(context.Background(), gw.URL, "https://admin.corezoid.com", false) + if got != sa { + t.Fatalf("want healed %q, got %q", sa, got) + } + if !strings.Contains(note, "Self-healed") || !strings.Contains(note, "admin.corezoid.com") || !strings.Contains(note, sa) { + t.Fatalf("note must explain what was wrong and what was done, got: %q", note) + } + data, err := os.ReadFile(envPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "ACCOUNT_URL="+sa) { + t.Fatalf(".env not healed: %s", data) + } +} + +func TestResolveAccountURL_MatchIsSilent(t *testing.T) { + sa := "https://account.example.com" + gw := fakeGateway(t, sa) + setupEnvFile(t, "") + + // Trailing slash and case must not count as a mismatch. + got, note := resolveAccountURL(context.Background(), gw.URL, "https://ACCOUNT.example.com/", false) + if got != "https://ACCOUNT.example.com/" { + t.Fatalf("matching env value must be kept as-is, got %q", got) + } + if note != "" { + t.Fatalf("no note expected when env matches the gateway, got: %q", note) + } +} + +func TestResolveAccountURL_GatewayUnreachableKeepsEnv(t *testing.T) { + setupEnvFile(t, "") + env := "https://account.onprem.example" + got, note := resolveAccountURL(context.Background(), "http://127.0.0.1:1/papi/1.0", env, false) + if got != env { + t.Fatalf("unreachable gateway must keep env value, got %q", got) + } + if !strings.Contains(note, "Could not verify") || !strings.Contains(note, env) { + t.Fatalf("note must say verification was skipped, got: %q", note) + } +} + +func TestResolveAccountURL_EmptyEnvDerivesFromGateway(t *testing.T) { + sa := "https://account.onprem.example" + gw := fakeGateway(t, sa) + envPath := setupEnvFile(t, "") + + got, note := resolveAccountURL(context.Background(), gw.URL, "", false) + if got != sa { + t.Fatalf("want derived %q, got %q", sa, got) + } + if !strings.Contains(note, "was not set") || !strings.Contains(note, sa) { + t.Fatalf("note must explain the derivation, got: %q", note) + } + data, _ := os.ReadFile(envPath) + if !strings.Contains(string(data), "ACCOUNT_URL="+sa) { + t.Fatalf(".env not persisted: %s", data) + } +} + +func TestLoginFailureMsg_OffersManualURLAndNextSteps(t *testing.T) { + authURL := "https://account.example.com/oauth2/authorize?client_id=x" + msg := loginFailureMsg(context.DeadlineExceeded, authURL, + "https://account.example.com", "https://gw.example.com/papi/1.0", "🔧 healed note") + for _, want := range []string{authURL, "set-environment", "healed note", "consent"} { + if !strings.Contains(msg, want) { + t.Fatalf("failure message missing %q:\n%s", want, msg) + } + } +} + +func TestProbeRequested(t *testing.T) { + cases := []struct { + args map[string]any + want bool + }{ + {map[string]any{"probe": true}, true}, + {map[string]any{"probe": false}, false}, + {map[string]any{"probe": "true"}, true}, + {map[string]any{"probe": "1"}, true}, + {map[string]any{"probe": "no"}, false}, + {map[string]any{}, false}, + } + for _, c := range cases { + if got := probeRequested(c.args); got != c.want { + t.Fatalf("probeRequested(%v) = %v, want %v", c.args, got, c.want) + } + } +} diff --git a/plugins/simulator/mcp-server/internal/tools/build.go b/plugins/simulator/mcp-server/internal/tools/build.go index b1aebb6..6721184 100644 --- a/plugins/simulator/mcp-server/internal/tools/build.go +++ b/plugins/simulator/mcp-server/internal/tools/build.go @@ -4,8 +4,10 @@ import ( "context" "fmt" "log" + "net/http" "os" "strings" + "time" "github.com/corezoid/simulator-ai-plugin/plugins/simulator/mcp-server/app/auth" "github.com/corezoid/simulator-ai-plugin/plugins/simulator/mcp-server/internal/apiclient" @@ -41,8 +43,9 @@ func allOps() []Operation { } // BuildAll registers every curated API tool plus the auth helpers (set-environment, -// login, set-workspace) on the MCP server. insecure is threaded through so the -// set-environment tool's public-config probe honours the same TLS mode as the client. +// login, logout, status, set-workspace) on the MCP server. insecure is threaded +// through so the public-config probes (set-environment, login self-heal, status) +// honour the same TLS mode as the client. func BuildAll(s *server.MCPServer, c *apiclient.Client, prof config.Profile, insecure bool) { BuildUnified(s, c, true) registerAuth(s, c, prof, insecure) @@ -107,24 +110,150 @@ func BuildUnified(s *server.MCPServer, c *apiclient.Client, includeActorMode boo func Count() int { return len(allOps()) } func registerAuth(s *server.MCPServer, c *apiclient.Client, prof config.Profile, insecure bool) { + // The auth endpoints (discovery, token exchange) must follow the same TLS + // mode as the API client, or a self-signed on-prem account service passes + // the config probe and then dies on the token POST. + auth.InsecureTLS = insecure registerSetEnvironment(s, c, prof, insecure) s.AddTool( mcp.NewTool("login", - mcp.WithDescription("Authenticate to Simulator via OAuth2 PKCE (opens a browser). Saves the token to .env. Run set-environment first if you haven't chosen an environment. After login, call set-workspace to choose a workspace."), + mcp.WithDescription("Authenticate to Simulator via OAuth2 PKCE (opens a browser). Before opening the browser it verifies ACCOUNT_URL against the chosen gateway's public config and self-heals it if a sibling tool overwrote it (works for cloud and on-prem gateways alike). Saves the token to .env. Run set-environment first if you haven't chosen an environment. After login, call set-workspace to choose a workspace."), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { // Prefer the account URL derived by set-environment (saved as ACCOUNT_URL) // over the startup profile default, so login follows the chosen environment. - accountURL := firstNonEmpty(os.Getenv("ACCOUNT_URL"), prof.AccountURL) - creds, err := auth.PKCEFlow(accountURL, prof.OAuthClientID, nil) + apiBase := firstNonEmpty(c.BaseURL(), os.Getenv("SIMULATOR_API_BASE_URL"), prof.APIBaseURL) + envAccount := firstNonEmpty(os.Getenv("ACCOUNT_URL"), prof.AccountURL) + accountURL, healNote := resolveAccountURL(ctx, apiBase, envAccount, insecure) + // OAuth 2.1 silent renewal: a stored refresh token skips the + // browser round-trip. Any failure falls back to the normal flow. + if rt := os.Getenv("REFRESH_TOKEN"); rt != "" { + if creds, rerr := auth.Refresh(ctx, accountURL, prof.OAuthClientID, rt); rerr != nil { + log.Printf("login: silent token refresh failed: %v — falling back to browser OAuth", rerr) + } else if verr := validateSimToken(ctx, apiBase, creds, insecure); verr != nil { + // Verified live: the account service's refresh grant currently + // mints an account-SESSION token the papi rejects — a refreshed + // token is only trusted after a real authenticated call succeeds. + log.Printf("login: refresh grant returned a token the API does not accept (%v) — falling back to browser OAuth", verr) + if creds.RefreshToken != "" { + // The AS rotated the refresh token on use — keep the + // newest one even though this access token was rejected, + // or a rotating AS would burn the stored token. + _ = auth.SaveRefreshToken(creds.RefreshToken) + } else { + // No rotation and the minted token is unusable — drop the + // stored one so future logins skip these round-trips (a + // browser login stores a fresh refresh token anyway). + _ = auth.DeleteRefreshToken() + } + } else { + if err := auth.Save(creds); err != nil { + return mcp.NewToolResultError(strings.TrimSpace(healNote + "\n" + fmt.Sprintf("[Error] authenticated, but failed to save the token to %s: %v — fix the file permissions and re-run login.", auth.EnvPath(), err))), nil + } + msg := "Authenticated: access token renewed silently via the stored refresh token — no browser login was needed. Next: call getWorkspaces to list your workspaces, show them to the user to pick one, then call set-workspace (by accId or name)." + if healNote != "" { + msg = healNote + "\n\n" + msg + } + return mcp.NewToolResultText(msg), nil + } + } + creds, authURL, err := auth.PKCEFlow(ctx, accountURL, prof.OAuthClientID, nil) if err != nil { - return mcp.NewToolResultError(fmt.Sprintf("[Error] OAuth2 login failed: %v", err)), nil + return mcp.NewToolResultError(loginFailureMsg(err, authURL, accountURL, apiBase, healNote)), nil } if err := auth.Save(creds); err != nil { - return mcp.NewToolResultError(fmt.Sprintf("[Error] failed to save token: %v", err)), nil + return mcp.NewToolResultError(strings.TrimSpace(healNote + "\n" + fmt.Sprintf("[Error] authenticated, but failed to save the token to %s: %v — fix the file permissions and re-run login.", auth.EnvPath(), err))), nil } - return mcp.NewToolResultText("Authenticated. Token saved to .env. Next: call getWorkspaces to list your workspaces, show them to the user to pick one, then call set-workspace (by accId or name)."), nil + msg := "Authenticated. Token saved to .env. Next: call getWorkspaces to list your workspaces, show them to the user to pick one, then call set-workspace (by accId or name)." + if healNote != "" { + msg = healNote + "\n\n" + msg + } + return mcp.NewToolResultText(msg), nil + }, + ) + + s.AddTool( + mcp.NewTool("logout", + mcp.WithDescription("Remove the saved token (ACCESS_TOKEN) from .env and from this process. The removed lines are first backed up to .env.bak. ⚠️ The .env file can be shared with sibling plugins (e.g. corezoid) — if they use the same token, they are logged out too."), + ), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + creds, _ := auth.Load() + if creds == nil && os.Getenv("REFRESH_TOKEN") == "" { + return mcp.NewToolResultText(fmt.Sprintf("No token found — nothing to log out (%s has no ACCESS_TOKEN or REFRESH_TOKEN).", auth.EnvPath())), nil + } + // Back up BEFORE removing: the .env is shared with sibling plugins, so an + // accidental logout must stay recoverable by hand. Backup failure aborts. + bak, bakErr := auth.BackupToken() + if bakErr != nil { + return mcp.NewToolResultError(fmt.Sprintf("[Error] could not back up the token before removal (%v) — nothing was changed. Fix the problem (is %s.bak writable?) or remove the ACCESS_TOKEN line from .env manually.", bakErr, auth.EnvPath())), nil + } + if err := auth.Delete(); err != nil { + return mcp.NewToolResultError(fmt.Sprintf("[Error] failed to remove the token from %s: %v — the token may still be present there; remove the ACCESS_TOKEN line manually.", auth.EnvPath(), err)), nil + } + engines.ResetAuth() + msg := "Logged out: ACCESS_TOKEN removed from .env and from this process." + if bak != "" { + msg += "\nBackup of the removed lines: " + bak + } + msg += "\n⚠️ If the corezoid plugin was sharing this token, it is logged out too. Re-run login to authenticate again." + return mcp.NewToolResultText(msg), nil + }, + ) + + s.AddTool( + mcp.NewTool("status", + mcp.WithDescription("Self-diagnosis: show the current environment (API gateway, OAuth account URL, workspace) and token state, with no side effects. Pass probe=true to also verify ACCOUNT_URL against the gateway's public config (one unauthenticated request; works for cloud and on-prem). Run this first when login or any tool misbehaves."), + mcp.WithBoolean("probe", mcp.Description("Also fetch the gateway's public config and check ACCOUNT_URL against its declared account service (saUrl).")), + ), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + apiBase := firstNonEmpty(c.BaseURL(), os.Getenv("SIMULATOR_API_BASE_URL"), prof.APIBaseURL) + envAccount := firstNonEmpty(os.Getenv("ACCOUNT_URL"), prof.AccountURL) + ws := firstNonEmpty(c.WorkspaceID(), os.Getenv("WORKSPACE_ID")) + + tokenLine := "absent — run login" + if creds, _ := auth.Load(); creds != nil { + switch { + case auth.IsExpired(creds): + tokenLine = fmt.Sprintf("EXPIRED %s — re-run login", creds.ExpiresAt.UTC().Format("2006-01-02 15:04")) + case creds.ExpiresAt.IsZero(): + tokenLine = "present (no expiry recorded)" + default: + tokenLine = "present, valid until " + creds.ExpiresAt.UTC().Format("2006-01-02 15:04") + } + } + accLine := envAccount + if envAccount == "" { + accLine = "(not set — login will derive it from the gateway config)" + } + wsLine := ws + if ws == "" { + wsLine = "(not set — run set-workspace after login)" + } + + var sb strings.Builder + fmt.Fprintf(&sb, "simulator-mcp status (profile: %s)\n", prof.Name) + fmt.Fprintf(&sb, " API gateway: %s\n", apiBase) + fmt.Fprintf(&sb, " ACCOUNT_URL: %s\n", accLine) + fmt.Fprintf(&sb, " workspace: %s\n", wsLine) + fmt.Fprintf(&sb, " token: %s\n", tokenLine) + fmt.Fprintf(&sb, " .env: %s\n", auth.EnvPath()) + + if probeRequested(req.GetArguments()) { + saURL, err := apiclient.FetchPublicConfig(ctx, apiBase, insecure) + switch { + case err != nil: + fmt.Fprintf(&sb, "probe: FAILED — the gateway config at %s is unreachable: %v. Check the network / the gateway URL; run set-environment to pick a reachable one.\n", apiBase, err) + case envAccount == "": + fmt.Fprintf(&sb, "probe: NOT SET — the gateway declares its account service at %s; login will adopt and save it automatically.\n", saURL) + case !sameBaseURL(saURL, envAccount): + fmt.Fprintf(&sb, "probe: MISMATCH — the gateway declares its account service at %s but ACCOUNT_URL is %s (a sibling tool sharing .env may have overwritten it). login will self-heal this automatically; set-environment also fixes it.\n", saURL, envAccount) + default: + fmt.Fprintf(&sb, "probe: OK — ACCOUNT_URL matches the account service declared by the gateway.\n") + } + } + sb.WriteString("If OTHER sessions report \"No such tool available\" for simulator tools, this server was restarted after they connected — those sessions must be RESTARTED (a plain reconnect may not be enough).") + return mcp.NewToolResultText(sb.String()), nil }, ) @@ -235,6 +364,97 @@ func registerSetEnvironment(s *server.MCPServer, c *apiclient.Client, prof confi ) } +// resolveAccountURL self-diagnoses the OAuth account URL before login opens a +// browser. The ACCOUNT_URL key in .env is shared with sibling plugins (corezoid) +// and can be overwritten with a host that serves no OAuth endpoints (e.g. the +// admin UI) — the browser then opens a dashboard and login dies silently. The +// source of truth for ANY environment, cloud or on-prem, is what the chosen +// gateway itself declares in its public config (saUrl), so we ask the gateway +// and self-heal .env when they disagree. The second return value is a note for +// the user describing what was found and what was done ("" when all is well). +func resolveAccountURL(ctx context.Context, apiBase, envAccount string, insecure bool) (string, string) { + saURL, err := apiclient.FetchPublicConfig(ctx, apiBase, insecure) + if err != nil || saURL == "" { + if envAccount == "" { + return "", fmt.Sprintf("⚠️ ACCOUNT_URL is not set and the gateway config at %s is unreachable (%v) — falling back to the default account service. If login fails, run set-environment to pick a reachable gateway.", apiBase, err) + } + return envAccount, fmt.Sprintf("⚠️ Could not verify ACCOUNT_URL against the gateway config at %s (%v) — proceeding with %s as-is.", apiBase, err, envAccount) + } + if sameBaseURL(saURL, envAccount) { + return envAccount, "" + } + note := fmt.Sprintf("🔧 Self-healed ACCOUNT_URL: .env had %q, but the gateway %s declares its account service at %s (a sibling tool sharing .env may have overwritten it). Using %s.", envAccount, apiBase, saURL, saURL) + if envAccount == "" { + note = fmt.Sprintf("ACCOUNT_URL was not set — derived %s from the gateway config at %s.", saURL, apiBase) + } + if err := auth.SaveAccountURL(saURL); err != nil { + note += fmt.Sprintf(" ⚠️ Could not persist it to .env (%v) — it will be re-derived on the next login.", err) + } else { + note += " Saved to .env." + } + return saURL, note +} + +// validateSimToken makes one authenticated papi call (GET /workspaces) with +// the candidate token, so a refresh-minted token is proven to work before it +// replaces the stored one. +func validateSimToken(ctx context.Context, apiBase string, creds *auth.Credentials, insecure bool) error { + u := strings.TrimRight(apiBase, "/") + "/workspaces" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", creds.AuthorizationHeader()) + resp, err := auth.NewHTTPClient(insecure, 15*time.Second).Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("papi answered %d to an authenticated call", resp.StatusCode) + } + return nil +} + +// sameBaseURL compares two service URLs ignoring a trailing slash and case. +func sameBaseURL(a, b string) bool { + return strings.EqualFold(strings.TrimRight(a, "/"), strings.TrimRight(b, "/")) +} + +// loginFailureMsg turns an OAuth failure into a self-diagnosis: what failed, +// the authorization URL for a manual retry, and the concrete next steps. +func loginFailureMsg(err error, authURL, accountURL, apiBase, healNote string) string { + var sb strings.Builder + fmt.Fprintf(&sb, "[Error] OAuth2 login failed: %v\n", err) + if healNote != "" { + sb.WriteString(healNote + "\n") + } + sb.WriteString("\nWhat to check / how to fix:\n") + step := 1 + if authURL != "" { + fmt.Fprintf(&sb, "%d. This consent URL was used (its callback listener is closed now — verify the host looks right, then RE-RUN login rather than opening it):\n %s\n", step, authURL) + step++ + } + fmt.Fprintf(&sb, "%d. Verify the environment: API gateway %s, OAuth account service %s. If either looks wrong, run set-environment (preset or url — on-prem servers included): it re-derives the account URL from the gateway itself.\n", step, apiBase, accountURL) + step++ + fmt.Fprintf(&sb, "%d. If the page showed a dashboard instead of a consent screen, ACCOUNT_URL pointed at the wrong host — set-environment fixes that.\n", step) + step++ + fmt.Fprintf(&sb, "%d. If it timed out, re-run login and complete the browser consent within 5 minutes.", step) + return sb.String() +} + +// probeRequested reads the status tool's probe argument, accepting a real +// boolean or the strings "true"/"1" (CLI-style leniency). +func probeRequested(args map[string]any) bool { + if b, ok := args["probe"].(bool); ok { + return b + } + if ps, ok := args["probe"].(string); ok { + return strings.EqualFold(ps, "true") || ps == "1" + } + return false +} + // presetNames returns the comma-separated list of preset selectors. func presetNames(presets []config.CloudEnv) string { names := make([]string, 0, len(presets)) diff --git a/plugins/simulator/mcp-server/internal/tools/eval_test.go b/plugins/simulator/mcp-server/internal/tools/eval_test.go index 0ae6948..c67d3a3 100644 --- a/plugins/simulator/mcp-server/internal/tools/eval_test.go +++ b/plugins/simulator/mcp-server/internal/tools/eval_test.go @@ -31,7 +31,7 @@ type argCheck struct { // API operations + the engine tools + the auth helpers. Engine/auth names are // listed explicitly because they are registered outside allOps(). func knownToolNames() map[string]bool { - m := map[string]bool{"login": true, "set-workspace": true} + m := map[string]bool{"login": true, "logout": true, "status": true, "set-workspace": true} for _, op := range allOps() { m[op.Name] = true } diff --git a/plugins/simulator/mcp-server/internal/tools/testdata/eval-scenarios.json b/plugins/simulator/mcp-server/internal/tools/testdata/eval-scenarios.json index e5d05e9..9f64973 100644 --- a/plugins/simulator/mcp-server/internal/tools/testdata/eval-scenarios.json +++ b/plugins/simulator/mcp-server/internal/tools/testdata/eval-scenarios.json @@ -1742,5 +1742,21 @@ "pushSmartForm" ], "liveOnly": true + }, + { + "name": "Diagnose connection with status", + "prompt": "Is the Simulator MCP server connected and is my token still valid? Check the server status.", + "tools": [ + "status" + ], + "liveOnly": true + }, + { + "name": "Sign out of Simulator", + "prompt": "Log me out of Simulator and remove my stored credentials.", + "tools": [ + "logout" + ], + "liveOnly": true } ] diff --git a/plugins/simulator/mcp-server/internal/tools/testmain_test.go b/plugins/simulator/mcp-server/internal/tools/testmain_test.go new file mode 100644 index 0000000..50fb7e1 --- /dev/null +++ b/plugins/simulator/mcp-server/internal/tools/testmain_test.go @@ -0,0 +1,24 @@ +package tools + +import ( + "os" + "testing" +) + +// TestMain pins SIMULATOR_WORK_DIR to a throwaway directory for the whole +// package: the credential helpers write to {SIMULATOR_WORK_DIR|cwd}/.env, and +// a test that forgets t.Setenv must corrupt a sandbox, not a real .env. +func TestMain(m *testing.M) { + tmp, err := os.MkdirTemp("", "sim-tools-test-*") + if err == nil { + os.Setenv("SIMULATOR_WORK_DIR", tmp) + } + os.Unsetenv("ACCESS_TOKEN") + os.Unsetenv("ACCESS_TOKEN_EXPIRES_AT") + os.Unsetenv("REFRESH_TOKEN") + code := m.Run() + if tmp != "" { + os.RemoveAll(tmp) + } + os.Exit(code) +}