Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/<x>`).
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
95 changes: 90 additions & 5 deletions plugins/simulator/mcp-server/app/auth/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -137,13 +139,21 @@ 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)
}
os.Setenv("ACCESS_TOKEN", creds.AccessToken)
if expStr != "" {
os.Setenv("ACCESS_TOKEN_EXPIRES_AT", expStr)
}
if creds.RefreshToken != "" {
os.Setenv("REFRESH_TOKEN", creds.RefreshToken)
}
return nil
}

Expand All @@ -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
}

Expand Down Expand Up @@ -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 == "" {
Expand Down
115 changes: 115 additions & 0 deletions plugins/simulator/mcp-server/app/auth/lifecycle_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading