From 2f0eb481ea3525b79dfedcd94ba5a0474f30887b Mon Sep 17 00:00:00 2001 From: Ivan Kondratyuk Date: Sat, 11 Jul 2026 23:03:00 +0300 Subject: [PATCH] feat(auth): self-heal, logout, status, OAuth best practices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit login now self-diagnoses before opening a browser: it verifies ACCOUNT_URL against the gateway's public config (saUrl — works for cloud and on-prem) and self-heals .env when a sibling tool sharing the file overwrote it, reporting what was fixed. OAuth failures explain what to check and include the authorization URL for a manual retry; the callback wait honours client cancellation. New tools: logout (removes ACCESS_TOKEN with a .env.bak backup first, warns about the shared .env) and status (no-side-effect diagnosis of environment, token state and .env path; probe=true checks ACCOUNT_URL against the gateway). OAuth hardening per OAuth 2.1 / RFC 8414 / RFC 8252: endpoint discovery via /.well-known/oauth-authorization-server with a conventional-path fallback, silent refresh_token renewal validated by one authenticated papi call before being trusted, ACCOUNT_URL scheme validation, bounded token requests, standard access_token accepted, and token-endpoint bodies never echoed into error messages. Tests run in a sandboxed SIMULATOR_WORK_DIR via TestMain so a suite run can never touch a real .env. --- .gitignore | 1 + CHANGELOG.md | 11 + README.md | 2 +- .../mcp-server/app/auth/credentials.go | 95 ++++++- .../mcp-server/app/auth/lifecycle_test.go | 115 +++++++++ .../simulator/mcp-server/app/auth/oauth.go | 218 ++++++++++++++-- .../app/auth/oauth_bestpractices_test.go | 163 ++++++++++++ .../mcp-server/app/auth/testmain_test.go | 24 ++ .../internal/tools/auth_selfheal_test.go | 137 ++++++++++ .../mcp-server/internal/tools/build.go | 241 +++++++++++++++++- .../internal/tools/testmain_test.go | 24 ++ 11 files changed, 994 insertions(+), 37 deletions(-) create mode 100644 plugins/simulator/mcp-server/app/auth/lifecycle_test.go create mode 100644 plugins/simulator/mcp-server/app/auth/oauth_bestpractices_test.go create mode 100644 plugins/simulator/mcp-server/app/auth/testmain_test.go create mode 100644 plugins/simulator/mcp-server/internal/tools/auth_selfheal_test.go create mode 100644 plugins/simulator/mcp-server/internal/tools/testmain_test.go diff --git a/.gitignore b/.gitignore index 00e45be..bd58dcb 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 7f1da0f..c0ef4f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [Unreleased] + +### 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.0] ### Added diff --git a/README.md b/README.md index 529c0c1..3e5901f 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/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..ae0a8dd 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,37 @@ 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 + +// authHTTPClient returns a client honouring InsecureTLS with the given timeout. +func authHTTPClient(timeout time.Duration) *http.Client { + tr := &http.Transport{} + if InsecureTLS { + tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec G402 — opt-in via --insecure + } + return &http.Client{Timeout: timeout, Transport: tr} +} + // 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 +66,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 +92,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 +171,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 +201,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 +343,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 +477,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..84aa7d8 100644 --- a/plugins/simulator/mcp-server/internal/tools/build.go +++ b/plugins/simulator/mcp-server/internal/tools/build.go @@ -2,10 +2,13 @@ package tools import ( "context" + "crypto/tls" "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 +44,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 +111,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 + } + 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)" } - 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 + + 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 +365,101 @@ 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()) + tr := &http.Transport{} + if insecure { + tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // #nosec G402 — opt-in via --insecure + } + resp, err := (&http.Client{Timeout: 15 * time.Second, Transport: tr}).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/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) +}