diff --git a/cmd/subrouter/main.go b/cmd/subrouter/main.go index 2c642e40c..425d6da10 100644 --- a/cmd/subrouter/main.go +++ b/cmd/subrouter/main.go @@ -303,6 +303,7 @@ func serve(args []string) error { stackPublishableClientKey := flags.String("stack-publishable-client-key", "", "Stack Auth publishable client key; defaults to SUBROUTER_STACK_PUBLISHABLE_CLIENT_KEY") stackTenantKeySecret := flags.String("stack-tenant-key-secret", "", "local-development override for stable Stack-team tenant keys; deployments use SUBROUTER_STACK_TENANT_KEY_SECRET") stackTenantDeleteToken := flags.String("stack-tenant-delete-token", "", "trusted cmux.com token required for hosted tenant exchange and deletion; deployments use SUBROUTER_STACK_TENANT_DELETE_TOKEN") + stackLegacyKeyGrace := flags.Duration("stack-legacy-key-grace", 30*24*time.Hour, "one-time grace for pre-broker Stack tenant keys; capped at 90 days") bedrockEnable := flags.Bool("bedrock", false, "enable the /bedrock/* AWS SigV4 signing gateway for Claude Code Bedrock mode") bedrockRegion := flags.String("bedrock-region", "us-east-1", "comma-separated AWS regions for the Bedrock signing gateway") bedrockGatewayToken := flags.String("bedrock-gateway-token", "", "optional bearer token clients must present to the Bedrock gateway; defaults to SUBROUTER_BEDROCK_GATEWAY_TOKEN") @@ -602,9 +603,10 @@ func serve(args []string) error { slog.Info("sr auto-switch disabled because usage fetching is disabled", "interval", srSwitchInterval.String()) } + tenantRegistry := tenant.NewRegistry(storepath.StateDir()) multiTenantHandler := &proxy.MultiTenant{ Base: server, - Registry: tenant.NewRegistry(storepath.StateDir()), + Registry: tenantRegistry, TranscriptDir: *transcriptDir, Enabled: *multiTenant, PublicURL: *publicURL, @@ -623,6 +625,18 @@ func serve(args []string) error { multiTenantHandler.StackProjectID = *stackProjectID multiTenantHandler.StackTenantKeySecret = []byte(*stackTenantKeySecret) multiTenantHandler.StackTenantDeleteToken = []byte(*stackTenantDeleteToken) + cutoff, err := tenantRegistry.EnsureLegacyCredentialCutoff( + time.Now(), + *stackLegacyKeyGrace, + ) + if err != nil { + return fmt.Errorf("initialize legacy Stack credential cutoff: %w", err) + } + multiTenantHandler.StackLegacyKeyCutoff = cutoff + slog.Info( + "legacy Stack tenant credentials have a fixed migration cutoff", + "cutoff", cutoff.Format(time.RFC3339), + ) } httpServer := &http.Server{ Addr: *addr, diff --git a/deploy/gcp/README.md b/deploy/gcp/README.md index de289d39b..ae5f8a029 100644 --- a/deploy/gcp/README.md +++ b/deploy/gcp/README.md @@ -98,9 +98,12 @@ sr codex The browser login uses the same Stack identity as cmux. The cmux.com exchange broker enforces team permissions and cutover readiness, then requests a capability-scoped tenant key from hosted Subrouter. Direct client exchange is -rejected. The CLI writes the tenant-scoped public URL to the local Codex -configuration. `sr remote use cmux-local` keeps the proxy on the Mac while -leasing short-lived access credentials from the same tenant. +rejected. A durable 30-day cutoff lets tenant keys issued before the broker +migration survive the deployment without extending their lifetime on restart; +after the cutoff they fail closed. A fresh `sr login` rotates to the scoped key. +The CLI writes the tenant-scoped public URL to the local Codex configuration. +`sr remote use cmux-local` keeps the proxy on the Mac while leasing short-lived +access credentials from the same tenant. Operators can add a fresh server-owned Codex OAuth account over authenticated HTTP when needed: diff --git a/internal/proxy/multitenant.go b/internal/proxy/multitenant.go index 53c658c47..9c3795e6a 100644 --- a/internal/proxy/multitenant.go +++ b/internal/proxy/multitenant.go @@ -55,7 +55,9 @@ type MultiTenant struct { StackProjectID string StackTenantKeySecret []byte StackTenantDeleteToken []byte + StackLegacyKeyCutoff time.Time PublicURL string + Now func() time.Time mu sync.Mutex servers map[string]*Server @@ -178,7 +180,7 @@ func (m *MultiTenant) serveResolvedTenant( http.Error(w, "unknown tenant key", http.StatusUnauthorized) return } - if m.isLegacyStackCredential(fresh.ID, key) { + if m.legacyStackCredentialExpired(fresh.ID, key) { http.Error(w, "unknown tenant key", http.StatusUnauthorized) return } @@ -205,7 +207,7 @@ func (m *MultiTenant) serveResolvedTenant( handler.ServeHTTP(w, scoped) } -func (m *MultiTenant) isLegacyStackCredential(tenantID, key string) bool { +func (m *MultiTenant) legacyStackCredentialExpired(tenantID, key string) bool { if strings.TrimSpace(m.StackProjectID) == "" || len(m.StackTenantKeySecret) < 32 { return false } @@ -214,7 +216,15 @@ func (m *MultiTenant) isLegacyStackCredential(tenantID, key string) bool { m.StackProjectID, tenantID, ) - return err == nil && subtle.ConstantTimeCompare([]byte(legacy), []byte(key)) == 1 + if err != nil || subtle.ConstantTimeCompare([]byte(legacy), []byte(key)) != 1 { + return false + } + now := time.Now() + if m.Now != nil { + now = m.Now() + } + return m.StackLegacyKeyCutoff.IsZero() || + !now.Before(m.StackLegacyKeyCutoff) } func tenantCredentialAllows(key tenant.Key, path, method string) bool { diff --git a/internal/proxy/multitenant_test.go b/internal/proxy/multitenant_test.go index 90ba1e7a0..7e3669669 100644 --- a/internal/proxy/multitenant_test.go +++ b/internal/proxy/multitenant_test.go @@ -673,7 +673,7 @@ func TestStackUseCredentialCannotManageTenantAccounts(t *testing.T) { } } -func TestLegacyDirectStackCredentialIsRejected(t *testing.T) { +func TestLegacyDirectStackCredentialExpiresAfterDeploymentGrace(t *testing.T) { secret := []byte("0123456789abcdef0123456789abcdef") legacyKey, err := tenant.DeriveKey(secret, "project", "team-123") if err != nil { @@ -684,21 +684,32 @@ func TestLegacyDirectStackCredentialIsRejected(t *testing.T) { t.Fatal(err) } base := Server{MaxBodyBytes: 1024} - handler := (&MultiTenant{ + now := time.Date(2026, time.August, 4, 0, 0, 0, 0, time.UTC) + multi := &MultiTenant{ Base: base, Registry: registry, StackProjectID: "project", StackTenantKeySecret: secret, - }).Handler(base.Handler()) - response := httptest.NewRecorder() - handler.ServeHTTP( - response, - httptest.NewRequest( + StackLegacyKeyCutoff: now.Add(30 * 24 * time.Hour), + Now: func() time.Time { return now }, + } + handler := multi.Handler(base.Handler()) + request := func() *httptest.ResponseRecorder { + response := httptest.NewRecorder() + handler.ServeHTTP( + response, + httptest.NewRequest( http.MethodGet, "/t/"+legacyKey+"/_subrouter/whoami", nil, - ), - ) - if response.Code != http.StatusUnauthorized { - t.Fatalf("status = %d, want 401: %s", response.Code, response.Body.String()) + ), + ) + return response + } + if response := request(); response.Code != http.StatusOK { + t.Fatalf("grace status = %d, want 200: %s", response.Code, response.Body.String()) + } + now = multi.StackLegacyKeyCutoff + if response := request(); response.Code != http.StatusUnauthorized { + t.Fatalf("expired status = %d, want 401: %s", response.Code, response.Body.String()) } } diff --git a/internal/tenant/tenant.go b/internal/tenant/tenant.go index 9960488b3..c8cb06d0a 100644 --- a/internal/tenant/tenant.go +++ b/internal/tenant/tenant.go @@ -24,6 +24,9 @@ import ( const KeyPrefix = "srt_" const keyRandomBytes = 16 const keyDisplayPrefixLen = len(KeyPrefix) + 8 +const legacyCredentialCutoffFile = "stack-legacy-key-cutoff" + +const MaxLegacyCredentialGrace = 90 * 24 * time.Hour type Key struct { Hash string `json:"hash"` @@ -62,7 +65,8 @@ type registryFile struct { // Registry reads and writes tenants.json under a server state dir. Reads are // cached on file modtime+size so the per-request key resolution is one stat. type Registry struct { - stateDir string + stateDir string + syncStateDir func() error mu sync.Mutex cached registryFile @@ -74,7 +78,12 @@ type Registry struct { var ErrTenantRetired = errors.New("tenant is retired") func NewRegistry(stateDir string) *Registry { - return &Registry{stateDir: stateDir} + return &Registry{ + stateDir: stateDir, + syncStateDir: func() error { + return syncDirectory(stateDir) + }, + } } func (r *Registry) Path() string { @@ -85,6 +94,93 @@ func (r *Registry) TenantsDir() string { return filepath.Join(r.stateDir, "tenants") } +// EnsureLegacyCredentialCutoff creates one durable deadline for credentials +// issued by the pre-broker Stack exchange. Repeated starts and overlapping +// worker generations read the original deadline instead of extending it. +func (r *Registry) EnsureLegacyCredentialCutoff( + now time.Time, + grace time.Duration, +) (time.Time, error) { + if now.IsZero() { + return time.Time{}, errors.New("legacy credential cutoff requires the current time") + } + if grace <= 0 || grace > MaxLegacyCredentialGrace { + return time.Time{}, fmt.Errorf( + "legacy credential grace must be positive and at most %s", + MaxLegacyCredentialGrace, + ) + } + r.mu.Lock() + defer r.mu.Unlock() + lock, err := r.lockRegistry() + if err != nil { + return time.Time{}, err + } + defer lock.Close() + path := filepath.Join(r.stateDir, legacyCredentialCutoffFile) + if info, statErr := os.Lstat(path); statErr == nil { + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return time.Time{}, errors.New("legacy credential cutoff is not a regular file") + } + body, readErr := os.ReadFile(path) + if readErr != nil { + return time.Time{}, readErr + } + cutoff, parseErr := time.Parse(time.RFC3339Nano, strings.TrimSpace(string(body))) + if parseErr != nil { + return time.Time{}, fmt.Errorf("parse legacy credential cutoff: %w", parseErr) + } + if err := r.syncStateDir(); err != nil { + return time.Time{}, fmt.Errorf("sync legacy credential cutoff directory: %w", err) + } + return cutoff.UTC(), nil + } else if !errors.Is(statErr, os.ErrNotExist) { + return time.Time{}, statErr + } + + cutoff := now.UTC().Add(grace) + temporary, err := os.CreateTemp(r.stateDir, ".stack-legacy-key-cutoff-*") + if err != nil { + return time.Time{}, err + } + temporaryPath := temporary.Name() + defer func() { _ = os.Remove(temporaryPath) }() + if err := temporary.Chmod(0o600); err != nil { + _ = temporary.Close() + return time.Time{}, err + } + if _, err := fmt.Fprintf(temporary, "%s\n", cutoff.Format(time.RFC3339Nano)); err != nil { + _ = temporary.Close() + return time.Time{}, err + } + if err := temporary.Sync(); err != nil { + _ = temporary.Close() + return time.Time{}, err + } + if err := temporary.Close(); err != nil { + return time.Time{}, err + } + if err := os.Rename(temporaryPath, path); err != nil { + return time.Time{}, err + } + if err := r.syncStateDir(); err != nil { + return time.Time{}, fmt.Errorf("sync legacy credential cutoff directory: %w", err) + } + return cutoff, nil +} + +func syncDirectory(path string) error { + directory, err := os.Open(path) + if err != nil { + return err + } + if err := directory.Sync(); err != nil { + _ = directory.Close() + return err + } + return directory.Close() +} + // Dir returns the tenant's isolated state dir. func (r *Registry) Dir(id string) string { return filepath.Join(r.TenantsDir(), id) diff --git a/internal/tenant/tenant_test.go b/internal/tenant/tenant_test.go index 4b303d1c0..097b815a7 100644 --- a/internal/tenant/tenant_test.go +++ b/internal/tenant/tenant_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" ) func TestCreateResolveRevoke(t *testing.T) { @@ -277,6 +278,73 @@ func TestExternalTenantRejectsTraversalAndWeakSecret(t *testing.T) { } } +func TestLegacyCredentialCutoffIsDurableAndNeverExtended(t *testing.T) { + root := t.TempDir() + now := time.Date(2026, time.August, 4, 0, 0, 0, 0, time.UTC) + first, err := NewRegistry(root).EnsureLegacyCredentialCutoff( + now, + 30*24*time.Hour, + ) + if err != nil { + t.Fatal(err) + } + if want := now.Add(30 * 24 * time.Hour); !first.Equal(want) { + t.Fatalf("cutoff = %s, want %s", first, want) + } + second, err := NewRegistry(root).EnsureLegacyCredentialCutoff( + now.Add(7*24*time.Hour), + 30*24*time.Hour, + ) + if err != nil { + t.Fatal(err) + } + if !second.Equal(first) { + t.Fatalf("cutoff extended from %s to %s", first, second) + } + info, err := os.Stat(filepath.Join(root, "stack-legacy-key-cutoff")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("cutoff mode = %o, want 600", info.Mode().Perm()) + } +} + +func TestLegacyCredentialCutoffReportsDirectorySyncFailure(t *testing.T) { + registry := NewRegistry(t.TempDir()) + want := errors.New("sync state directory") + syncCalls := 0 + registry.syncStateDir = func() error { + syncCalls++ + if syncCalls == 1 { + return want + } + return nil + } + now := time.Date(2026, time.August, 4, 0, 0, 0, 0, time.UTC) + + _, err := registry.EnsureLegacyCredentialCutoff( + now, + 30*24*time.Hour, + ) + if !errors.Is(err, want) { + t.Fatalf("cutoff creation error = %v, want %v", err, want) + } + cutoff, err := registry.EnsureLegacyCredentialCutoff( + now.Add(7*24*time.Hour), + 30*24*time.Hour, + ) + if err != nil { + t.Fatal(err) + } + if wantCutoff := now.Add(30 * 24 * time.Hour); !cutoff.Equal(wantCutoff) { + t.Fatalf("recovered cutoff = %s, want %s", cutoff, wantCutoff) + } + if syncCalls != 2 { + t.Fatalf("directory sync calls = %d, want 2", syncCalls) + } +} + func mustRead(t *testing.T, path string) string { t.Helper() body, err := os.ReadFile(path)