Skip to content
Merged
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
16 changes: 15 additions & 1 deletion cmd/subrouter/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
9 changes: 6 additions & 3 deletions deploy/gcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +101 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the configurable grace period.

Line 101 states a fixed 30-day cutoff. --stack-legacy-key-grace permits a configured grace period up to 90 days. State that 30 days is the default and document the flag and cap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@deploy/gcp/README.md` around lines 101 - 104, Update the deployment README
text describing the legacy-key cutoff to state that 30 days is the default,
document the --stack-legacy-key-grace option for configuring the grace period,
and specify its maximum of 90 days.

`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:
Expand Down
16 changes: 13 additions & 3 deletions internal/proxy/multitenant.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand All @@ -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 {
Expand Down
33 changes: 22 additions & 11 deletions internal/proxy/multitenant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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())
}
}

Expand Down
100 changes: 98 additions & 2 deletions internal/tenant/tenant.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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)
Expand Down
68 changes: 68 additions & 0 deletions internal/tenant/tenant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)

func TestCreateResolveRevoke(t *testing.T) {
Expand Down Expand Up @@ -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)
Expand Down