From 74672dce752ed4df8ece7653242d9f191345bf5e Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 17:05:39 +0200 Subject: [PATCH 01/49] feat(apps-proxy): add kai-preview config keys Add KaiPreview struct with HandshakeSigningKey, SessionSigningKey, SessionTTL (default 4h), and AllowedIDEOrigins fields. Wire it into Config and add tests for defaults and required-field validation. --- .../pkg/service/appsproxy/config/config.go | 12 ++++++++ .../service/appsproxy/config/config_test.go | 29 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 internal/pkg/service/appsproxy/config/config_test.go diff --git a/internal/pkg/service/appsproxy/config/config.go b/internal/pkg/service/appsproxy/config/config.go index 198345343a..d92f366f7d 100644 --- a/internal/pkg/service/appsproxy/config/config.go +++ b/internal/pkg/service/appsproxy/config/config.go @@ -24,10 +24,19 @@ type Config struct { Upstream Upstream `configKey:"-" configUsage:"Configuration options for upstream"` SandboxesAPI SandboxesAPI `configKey:"sandboxesAPI"` CsrfTokenSalt string `configKey:"csrfTokenSalt" configUsage:"Salt used for generating CSRF tokens" validate:"required" sensitive:"true"` + KaiPreview KaiPreview `configKey:"kaiPreview"` K8s K8s `configKey:"k8s" configUsage:"Kubernetes configuration."` E2bWebhook E2BWebhook `configKey:"e2bWebhook"` } +// KaiPreview configures the stateless iframe-auth path for the kai-preview flow. +type KaiPreview struct { + HandshakeSigningKey string `configKey:"handshakeSigningKey" configUsage:"HMAC key for kai-preview handshake JWT (30-60s lifetime)." validate:"required" sensitive:"true"` + SessionSigningKey string `configKey:"sessionSigningKey" configUsage:"HMAC key for kai-preview session cookie JWT." validate:"required" sensitive:"true"` + SessionTTL time.Duration `configKey:"sessionTTL" configUsage:"Lifetime of the kai-preview session cookie (sliding)."` + AllowedIDEOrigins []string `configKey:"allowedIdeOrigins" configUsage:"Origins allowed to mint kai-preview embed tokens (e.g. https://connection.keboola.com)." validate:"required,min=1,dive,url"` +} + type API struct { Listen string `configKey:"listen" configUsage:"Listen address of the configuration HTTP API." validate:"required,hostname_port"` PublicURL *url.URL `configKey:"publicUrl" configUsage:"Public URL of the configuration HTTP API for link generation." validate:"required"` @@ -74,6 +83,9 @@ func New() Config { Host: "localhost:8000", }, }, + KaiPreview: KaiPreview{ + SessionTTL: 4 * time.Hour, + }, } } diff --git a/internal/pkg/service/appsproxy/config/config_test.go b/internal/pkg/service/appsproxy/config/config_test.go new file mode 100644 index 0000000000..2f949631e4 --- /dev/null +++ b/internal/pkg/service/appsproxy/config/config_test.go @@ -0,0 +1,29 @@ +package config_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/config" + "github.com/keboola/keboola-as-code/internal/pkg/service/common/configmap" +) + +func TestKaiPreviewConfig_Defaults(t *testing.T) { + t.Parallel() + cfg := config.New() + assert.Equal(t, 4*time.Hour, cfg.KaiPreview.SessionTTL) +} + +func TestKaiPreviewConfig_RequiresSigningKeys(t *testing.T) { + t.Parallel() + cfg := config.New() + cfg.CookieSecretSalt = "x" + cfg.CsrfTokenSalt = "x" + // KaiPreview signing keys intentionally empty + err := configmap.ValidateAndNormalize(&cfg) + require.Error(t, err) + assert.Contains(t, err.Error(), "kaiPreview.handshakeSigningKey") +} From e5298b4c58ae950e62b35bab2a3b9100d3bd32a7 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 17:13:25 +0200 Subject: [PATCH 02/49] fix(apps-proxy): tighten kai-preview config validation --- internal/pkg/service/appsproxy/config/config.go | 4 ++-- internal/pkg/service/appsproxy/config/config_test.go | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/pkg/service/appsproxy/config/config.go b/internal/pkg/service/appsproxy/config/config.go index d92f366f7d..e557060fa8 100644 --- a/internal/pkg/service/appsproxy/config/config.go +++ b/internal/pkg/service/appsproxy/config/config.go @@ -24,7 +24,7 @@ type Config struct { Upstream Upstream `configKey:"-" configUsage:"Configuration options for upstream"` SandboxesAPI SandboxesAPI `configKey:"sandboxesAPI"` CsrfTokenSalt string `configKey:"csrfTokenSalt" configUsage:"Salt used for generating CSRF tokens" validate:"required" sensitive:"true"` - KaiPreview KaiPreview `configKey:"kaiPreview"` + KaiPreview KaiPreview `configKey:"kaiPreview" configUsage:"kai-preview iframe-auth configuration."` K8s K8s `configKey:"k8s" configUsage:"Kubernetes configuration."` E2bWebhook E2BWebhook `configKey:"e2bWebhook"` } @@ -34,7 +34,7 @@ type KaiPreview struct { HandshakeSigningKey string `configKey:"handshakeSigningKey" configUsage:"HMAC key for kai-preview handshake JWT (30-60s lifetime)." validate:"required" sensitive:"true"` SessionSigningKey string `configKey:"sessionSigningKey" configUsage:"HMAC key for kai-preview session cookie JWT." validate:"required" sensitive:"true"` SessionTTL time.Duration `configKey:"sessionTTL" configUsage:"Lifetime of the kai-preview session cookie (sliding)."` - AllowedIDEOrigins []string `configKey:"allowedIdeOrigins" configUsage:"Origins allowed to mint kai-preview embed tokens (e.g. https://connection.keboola.com)." validate:"required,min=1,dive,url"` + AllowedIDEOrigins []string `configKey:"allowedIdeOrigins" configUsage:"Origins allowed to mint kai-preview embed tokens (e.g. https://connection.keboola.com)." validate:"required,min=1,dive,http_url"` } type API struct { diff --git a/internal/pkg/service/appsproxy/config/config_test.go b/internal/pkg/service/appsproxy/config/config_test.go index 2f949631e4..6baaaa1687 100644 --- a/internal/pkg/service/appsproxy/config/config_test.go +++ b/internal/pkg/service/appsproxy/config/config_test.go @@ -26,4 +26,5 @@ func TestKaiPreviewConfig_RequiresSigningKeys(t *testing.T) { err := configmap.ValidateAndNormalize(&cfg) require.Error(t, err) assert.Contains(t, err.Error(), "kaiPreview.handshakeSigningKey") + assert.Contains(t, err.Error(), "kaiPreview.sessionSigningKey") } From f3ea3e38ede07b2f81b08f868791897cf615eafd Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 17:18:13 +0200 Subject: [PATCH 03/49] feat(apps-proxy): kai-preview JWT mint/verify helpers --- go.mod | 2 +- .../apphandler/authproxy/kaipreview/jwt.go | 130 ++++++++++++++++++ .../authproxy/kaipreview/jwt_test.go | 101 ++++++++++++++ 3 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt.go create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt_test.go diff --git a/go.mod b/go.mod index e019d44be1..8656864592 100644 --- a/go.mod +++ b/go.mod @@ -302,7 +302,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/gohugoio/hashstructure v0.6.0 // indirect github.com/gohugoio/hugo v0.147.6 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang/gddo v0.0.0-20210115222349-20d68f94ee1f // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/protobuf v1.5.4 // indirect diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt.go new file mode 100644 index 0000000000..8d3bf424c5 --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt.go @@ -0,0 +1,130 @@ +// Package kaipreview implements the kai-preview iframe-auth path: a stateless +// JWT handshake (mint → bootstrap → exchange) that yields a host-only session +// cookie for embedding dev-mode data apps inside the kbc-ui SPA. +package kaipreview + +import ( + "crypto/rand" + "encoding/hex" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/jonboulle/clockwork" + + "github.com/keboola/keboola-as-code/internal/pkg/utils/errors" +) + +const ( + purposeHandshake = "kai-preview-embed" + purposeSession = "kai-preview-session" + handshakeTTL = 60 * time.Second +) + +// HandshakeClaims carries the authorization scope from mint to exchange. +type HandshakeClaims struct { + AppID string `json:"app_id"` + ProjectID string `json:"project"` + Purpose string `json:"purpose"` + JTI string `json:"jti"` + jwt.RegisteredClaims +} + +// SessionClaims carries the authorization scope inside the session cookie. +type SessionClaims struct { + AppID string `json:"app_id"` + ProjectID string `json:"project"` + Purpose string `json:"purpose"` + TTL int64 `json:"ttl_s"` // total intended lifetime in seconds (for halfway-refresh detection) + jwt.RegisteredClaims +} + +// NeedsRefresh returns true when the cookie has passed the midpoint of its TTL. +func (c SessionClaims) NeedsRefresh(now time.Time) bool { + if c.IssuedAt == nil { + return true + } + elapsed := now.Sub(c.IssuedAt.Time) + return elapsed*2 > time.Duration(c.TTL)*time.Second +} + +func MintHandshakeJWT(key string, clock clockwork.Clock, appID, projectID string) (string, error) { + now := clock.Now() + jti, err := randomHex(16) + if err != nil { + return "", errors.Errorf("kai-preview: generate jti: %w", err) + } + claims := HandshakeClaims{ + AppID: appID, + ProjectID: projectID, + Purpose: purposeHandshake, + JTI: jti, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(handshakeTTL)), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(key)) + if err != nil { + return "", errors.Errorf("kai-preview: sign handshake JWT: %w", err) + } + return signed, nil +} + +func VerifyHandshakeJWT(key string, clock clockwork.Clock, raw string) (*HandshakeClaims, error) { + claims := &HandshakeClaims{} + parser := jwt.NewParser(jwt.WithTimeFunc(clock.Now), jwt.WithValidMethods([]string{"HS256"})) + _, err := parser.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) { + return []byte(key), nil + }) + if err != nil { + return nil, errors.Errorf("kai-preview: verify handshake JWT: %w", err) + } + if claims.Purpose != purposeHandshake { + return nil, errors.Errorf("kai-preview: handshake JWT has wrong purpose: %q", claims.Purpose) + } + return claims, nil +} + +func MintSessionJWT(key string, clock clockwork.Clock, appID, projectID string, ttl time.Duration) (string, error) { + now := clock.Now() + claims := SessionClaims{ + AppID: appID, + ProjectID: projectID, + Purpose: purposeSession, + TTL: int64(ttl.Seconds()), + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString([]byte(key)) + if err != nil { + return "", errors.Errorf("kai-preview: sign session JWT: %w", err) + } + return signed, nil +} + +func VerifySessionJWT(key string, clock clockwork.Clock, raw string) (*SessionClaims, error) { + claims := &SessionClaims{} + parser := jwt.NewParser(jwt.WithTimeFunc(clock.Now), jwt.WithValidMethods([]string{"HS256"})) + _, err := parser.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) { + return []byte(key), nil + }) + if err != nil { + return nil, errors.Errorf("kai-preview: verify session JWT: %w", err) + } + if claims.Purpose != purposeSession { + return nil, errors.Errorf("kai-preview: session JWT has wrong purpose: %q", claims.Purpose) + } + return claims, nil +} + +func randomHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt_test.go new file mode 100644 index 0000000000..a75ad6e820 --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt_test.go @@ -0,0 +1,101 @@ +package kaipreview + +import ( + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testHandshakeKey = "test-handshake-key-must-be-long-enough" + testSessionKey = "test-session-key-also-long-enough" +) + +func TestHandshakeJWT_RoundTrip(t *testing.T) { + t.Parallel() + clock := clockwork.NewFakeClock() + + token, err := MintHandshakeJWT(testHandshakeKey, clock, "app-123", "proj-456") + require.NoError(t, err) + require.NotEmpty(t, token) + + claims, err := VerifyHandshakeJWT(testHandshakeKey, clock, token) + require.NoError(t, err) + assert.Equal(t, "app-123", claims.AppID) + assert.Equal(t, "proj-456", claims.ProjectID) + assert.Equal(t, "kai-preview-embed", claims.Purpose) + assert.NotEmpty(t, claims.JTI) +} + +func TestHandshakeJWT_ExpiredAfter60s(t *testing.T) { + t.Parallel() + clock := clockwork.NewFakeClock() + + token, err := MintHandshakeJWT(testHandshakeKey, clock, "app-123", "proj-456") + require.NoError(t, err) + + clock.Advance(61 * time.Second) + + _, err = VerifyHandshakeJWT(testHandshakeKey, clock, token) + require.Error(t, err) + assert.Contains(t, err.Error(), "expired") +} + +func TestHandshakeJWT_WrongKeyRejected(t *testing.T) { + t.Parallel() + clock := clockwork.NewFakeClock() + + token, err := MintHandshakeJWT(testHandshakeKey, clock, "app-123", "proj-456") + require.NoError(t, err) + + _, err = VerifyHandshakeJWT("different-key-different-length", clock, token) + require.Error(t, err) +} + +func TestSessionJWT_RoundTrip(t *testing.T) { + t.Parallel() + clock := clockwork.NewFakeClock() + + token, err := MintSessionJWT(testSessionKey, clock, "app-123", "proj-456", 4*time.Hour) + require.NoError(t, err) + + claims, err := VerifySessionJWT(testSessionKey, clock, token) + require.NoError(t, err) + assert.Equal(t, "app-123", claims.AppID) + assert.Equal(t, "proj-456", claims.ProjectID) + assert.Equal(t, "kai-preview-session", claims.Purpose) +} + +func TestSessionJWT_ExpiredAfterTTL(t *testing.T) { + t.Parallel() + clock := clockwork.NewFakeClock() + + token, err := MintSessionJWT(testSessionKey, clock, "app-123", "proj-456", 4*time.Hour) + require.NoError(t, err) + + clock.Advance(4*time.Hour + time.Second) + + _, err = VerifySessionJWT(testSessionKey, clock, token) + require.Error(t, err) +} + +func TestSessionJWT_HalfwayDetection(t *testing.T) { + t.Parallel() + clock := clockwork.NewFakeClock() + + token, err := MintSessionJWT(testSessionKey, clock, "app-123", "proj-456", 4*time.Hour) + require.NoError(t, err) + + clock.Advance(1 * time.Hour) // < 50% — should not need refresh + claims, err := VerifySessionJWT(testSessionKey, clock, token) + require.NoError(t, err) + assert.False(t, claims.NeedsRefresh(clock.Now())) + + clock.Advance(2 * time.Hour) // total 3h, > 50% of 4h + claims, err = VerifySessionJWT(testSessionKey, clock, token) + require.NoError(t, err) + assert.True(t, claims.NeedsRefresh(clock.Now())) +} From 9ec1387900903626d77157ff974b90b4ccadc94b Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 17:27:40 +0200 Subject: [PATCH 04/49] fix(apps-proxy): use RegisteredClaims.ID for kai-preview JWT jti --- go.mod | 2 +- .../appsproxy/proxy/apphandler/authproxy/kaipreview/jwt.go | 3 +-- .../proxy/apphandler/authproxy/kaipreview/jwt_test.go | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 8656864592..4323a0bf3b 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 github.com/go-playground/validator/v10 v10.30.2 github.com/go-resty/resty/v2 v2.17.2 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gofrs/flock v0.13.0 github.com/gofrs/uuid/v5 v5.4.0 github.com/google/go-cmp v0.7.0 @@ -302,7 +303,6 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/gohugoio/hashstructure v0.6.0 // indirect github.com/gohugoio/hugo v0.147.6 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang/gddo v0.0.0-20210115222349-20d68f94ee1f // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/protobuf v1.5.4 // indirect diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt.go index 8d3bf424c5..f9c9f8e977 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt.go @@ -25,7 +25,6 @@ type HandshakeClaims struct { AppID string `json:"app_id"` ProjectID string `json:"project"` Purpose string `json:"purpose"` - JTI string `json:"jti"` jwt.RegisteredClaims } @@ -57,8 +56,8 @@ func MintHandshakeJWT(key string, clock clockwork.Clock, appID, projectID string AppID: appID, ProjectID: projectID, Purpose: purposeHandshake, - JTI: jti, RegisteredClaims: jwt.RegisteredClaims{ + ID: jti, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(handshakeTTL)), }, diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt_test.go index a75ad6e820..e80ff9d16f 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt_test.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt_test.go @@ -27,7 +27,7 @@ func TestHandshakeJWT_RoundTrip(t *testing.T) { assert.Equal(t, "app-123", claims.AppID) assert.Equal(t, "proj-456", claims.ProjectID) assert.Equal(t, "kai-preview-embed", claims.Purpose) - assert.NotEmpty(t, claims.JTI) + assert.NotEmpty(t, claims.ID) } func TestHandshakeJWT_ExpiredAfter60s(t *testing.T) { From ef39afa720cf4daad65a18fdf96158a2b64aa7ff Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 17:30:29 +0200 Subject: [PATCH 05/49] feat(apps-proxy): kai-preview STA token verifier --- .../authproxy/kaipreview/sta_verifier.go | 61 +++++++++++++++++++ .../authproxy/kaipreview/sta_verifier_test.go | 51 ++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/sta_verifier.go create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/sta_verifier_test.go diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/sta_verifier.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/sta_verifier.go new file mode 100644 index 0000000000..f8e4d92843 --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/sta_verifier.go @@ -0,0 +1,61 @@ +package kaipreview + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/keboola/keboola-as-code/internal/pkg/utils/errors" +) + +// STAVerifyResult is the subset of Storage API's tokens/verify response that the +// kai-preview flow consumes. We deliberately ignore email, name, roles — see +// docs/superpowers/specs/2026-05-14-dev-iframe-auth-design.md "no identity in +// transit". +type STAVerifyResult struct { + ProjectID string +} + +type STAVerifier struct { + baseURL string + client *http.Client +} + +func NewSTAVerifier(baseURL string, client *http.Client) *STAVerifier { + return &STAVerifier{baseURL: baseURL, client: client} +} + +func (v *STAVerifier) Verify(ctx context.Context, token string) (*STAVerifyResult, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.baseURL+"/v2/storage/tokens/verify", nil) + if err != nil { + return nil, errors.Errorf("kai-preview: build STA verify request: %w", err) + } + req.Header.Set("X-StorageApi-Token", token) + req.Header.Set("Accept", "application/json") + + resp, err := v.client.Do(req) + if err != nil { + return nil, errors.Errorf("kai-preview: STA verify call: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return nil, errors.New("kai-preview: STA token unauthorized") + } + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("kai-preview: STA verify returned %d", resp.StatusCode) + } + + var body struct { + Owner struct { + ID string `json:"id"` + } `json:"owner"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, errors.Errorf("kai-preview: decode STA verify response: %w", err) + } + if body.Owner.ID == "" { + return nil, errors.New("kai-preview: STA verify response missing owner.id") + } + return &STAVerifyResult{ProjectID: body.Owner.ID}, nil +} diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/sta_verifier_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/sta_verifier_test.go new file mode 100644 index 0000000000..52acf775c4 --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/sta_verifier_test.go @@ -0,0 +1,51 @@ +package kaipreview + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSTAVerifier_Success(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + assert.Equal(t, "/v2/storage/tokens/verify", r.URL.Path) + assert.Equal(t, "test-token", r.Header.Get("X-StorageApi-Token")) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "owner": map[string]any{"id": "proj-456", "name": "Test Project"}, + }) + })) + defer srv.Close() + + v := NewSTAVerifier(srv.URL, srv.Client()) + res, err := v.Verify(context.Background(), "test-token") + require.NoError(t, err) + assert.Equal(t, "proj-456", res.ProjectID) +} + +func TestSTAVerifier_Unauthorized(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) + })) + defer srv.Close() + + v := NewSTAVerifier(srv.URL, srv.Client()) + _, err := v.Verify(context.Background(), "bad-token") + require.Error(t, err) + assert.Contains(t, err.Error(), "unauthorized") +} + +func TestSTAVerifier_NetworkError(t *testing.T) { + t.Parallel() + v := NewSTAVerifier("http://127.0.0.1:1", http.DefaultClient) // port 1 = unreachable + _, err := v.Verify(context.Background(), "token") + require.Error(t, err) +} From 82035d455a049a74ecf61a2bdd5e3f699991ac3b Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 17:34:40 +0200 Subject: [PATCH 06/49] test(apps-proxy): cover kai-preview STA verifier error paths --- .../authproxy/kaipreview/sta_verifier_test.go | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/sta_verifier_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/sta_verifier_test.go index 52acf775c4..8ce61506d5 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/sta_verifier_test.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/sta_verifier_test.go @@ -49,3 +49,45 @@ func TestSTAVerifier_NetworkError(t *testing.T) { _, err := v.Verify(context.Background(), "token") require.Error(t, err) } + +func TestSTAVerifier_MissingOwnerID(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"owner":{}}`)) + })) + defer srv.Close() + + v := NewSTAVerifier(srv.URL, srv.Client()) + _, err := v.Verify(context.Background(), "token") + require.Error(t, err) + assert.Contains(t, err.Error(), "missing owner.id") +} + +func TestSTAVerifier_ServerError(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "internal error", http.StatusInternalServerError) + })) + defer srv.Close() + + v := NewSTAVerifier(srv.URL, srv.Client()) + _, err := v.Verify(context.Background(), "token") + require.Error(t, err) + assert.Contains(t, err.Error(), "500") +} + +func TestSTAVerifier_MalformedJSON(t *testing.T) { + t.Parallel() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("not-json")) + })) + defer srv.Close() + + v := NewSTAVerifier(srv.URL, srv.Client()) + _, err := v.Verify(context.Background(), "token") + require.Error(t, err) + assert.Contains(t, err.Error(), "decode STA verify response") +} From 86afbf8aad21f2193b394698f933a7c107398c08 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 17:36:43 +0200 Subject: [PATCH 07/49] feat(apps-proxy): kai-preview host-only Partitioned session cookie --- .../apphandler/authproxy/kaipreview/cookie.go | 51 ++++++++++++++ .../authproxy/kaipreview/cookie_test.go | 70 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie.go create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie_test.go diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie.go new file mode 100644 index 0000000000..276f0a5afc --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie.go @@ -0,0 +1,51 @@ +package kaipreview + +import ( + "net/http" + "time" +) + +const SessionCookieName = "kbc-kai-preview-session" + +// SetSessionCookie writes the kai-preview session cookie on w. The cookie is +// host-only (no Domain), Secure, HttpOnly, SameSite=None, Partitioned (CHIPS). +// See docs/superpowers/specs/2026-05-14-dev-iframe-auth-design.md for the full +// attribute rationale. +func SetSessionCookie(w http.ResponseWriter, jwt string, ttl time.Duration) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookieName, + Value: jwt, + Path: "/", + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteNoneMode, + Partitioned: true, + MaxAge: int(ttl.Seconds()), + }) +} + +// ClearSessionCookie writes a cookie that invalidates any existing kai-preview +// session cookie on the same host. Used by the exchange endpoint on validation +// failure and by future sign-out flows. +func ClearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookieName, + Value: "", + Path: "/", + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteNoneMode, + Partitioned: true, + MaxAge: -1, + }) +} + +// ReadSessionCookie returns the value of the kai-preview session cookie if +// present on the request, or "" if absent or unreadable. +func ReadSessionCookie(r *http.Request) string { + c, err := r.Cookie(SessionCookieName) + if err != nil || c == nil { + return "" + } + return c.Value +} diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie_test.go new file mode 100644 index 0000000000..3714588e46 --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie_test.go @@ -0,0 +1,70 @@ +package kaipreview + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSetSessionCookie_Attributes(t *testing.T) { + t.Parallel() + w := httptest.NewRecorder() + clock := clockwork.NewFakeClock() + ttl := 4 * time.Hour + + jwt, err := MintSessionJWT(testSessionKey, clock, "app-123", "proj-456", ttl) + require.NoError(t, err) + + SetSessionCookie(w, jwt, ttl) + + resp := w.Result() + cookies := resp.Cookies() + require.Len(t, cookies, 1) + c := cookies[0] + + assert.Equal(t, SessionCookieName, c.Name) + assert.Equal(t, jwt, c.Value) + assert.Equal(t, "/", c.Path) + assert.True(t, c.Secure) + assert.True(t, c.HttpOnly) + assert.Equal(t, http.SameSiteNoneMode, c.SameSite) + assert.True(t, c.Partitioned) + assert.Empty(t, c.Domain, "must be host-only — no Domain attribute") + assert.Equal(t, int(ttl.Seconds()), c.MaxAge) +} + +func TestReadSessionCookie_Present(t *testing.T) { + t.Parallel() + r := httptest.NewRequest("GET", "/", nil) + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "the-jwt"}) + got := ReadSessionCookie(r) + assert.Equal(t, "the-jwt", got) +} + +func TestReadSessionCookie_Missing(t *testing.T) { + t.Parallel() + r := httptest.NewRequest("GET", "/", nil) + got := ReadSessionCookie(r) + assert.Empty(t, got) +} + +func TestClearSessionCookie_Attributes(t *testing.T) { + t.Parallel() + w := httptest.NewRecorder() + ClearSessionCookie(w) + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + c := cookies[0] + assert.Equal(t, SessionCookieName, c.Name) + assert.Empty(t, c.Value) + assert.Equal(t, -1, c.MaxAge, "clear cookie must use MaxAge=-1") + assert.True(t, c.Secure) + assert.True(t, c.HttpOnly) + assert.True(t, c.Partitioned) + assert.Equal(t, http.SameSiteNoneMode, c.SameSite) +} From d4352cf9d10a52692a9ead502cdfbce8649a51c9 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 17:42:08 +0200 Subject: [PATCH 08/49] fix(apps-proxy): kai-preview SetSessionCookie clears on non-positive ttl --- .../proxy/apphandler/authproxy/kaipreview/cookie.go | 7 +++++-- .../apphandler/authproxy/kaipreview/cookie_test.go | 10 ++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie.go index 276f0a5afc..c3e4b92401 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie.go @@ -9,9 +9,12 @@ const SessionCookieName = "kbc-kai-preview-session" // SetSessionCookie writes the kai-preview session cookie on w. The cookie is // host-only (no Domain), Secure, HttpOnly, SameSite=None, Partitioned (CHIPS). -// See docs/superpowers/specs/2026-05-14-dev-iframe-auth-design.md for the full -// attribute rationale. +// See the kai-preview design doc for the full attribute rationale. func SetSessionCookie(w http.ResponseWriter, jwt string, ttl time.Duration) { + if ttl <= 0 { + ClearSessionCookie(w) + return + } http.SetCookie(w, &http.Cookie{ Name: SessionCookieName, Value: jwt, diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie_test.go index 3714588e46..704be0b027 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie_test.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie_test.go @@ -53,6 +53,16 @@ func TestReadSessionCookie_Missing(t *testing.T) { assert.Empty(t, got) } +func TestSetSessionCookie_NonPositiveTTL_ClearsInstead(t *testing.T) { + t.Parallel() + w := httptest.NewRecorder() + SetSessionCookie(w, "ignored-jwt", 0) + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + assert.Equal(t, -1, cookies[0].MaxAge, "ttl=0 must invalidate, not create a session cookie") + assert.Empty(t, cookies[0].Value) +} + func TestClearSessionCookie_Attributes(t *testing.T) { t.Parallel() w := httptest.NewRecorder() From 9e202761c533e93c73d7ee0fa37ecd3bcc20ae94 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 17:44:18 +0200 Subject: [PATCH 09/49] feat(apps-proxy): kai-preview CORS preflight + response headers --- .../apphandler/authproxy/kaipreview/cors.go | 49 +++++++++++++++ .../authproxy/kaipreview/cors_test.go | 60 +++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cors.go create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cors_test.go diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cors.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cors.go new file mode 100644 index 0000000000..082974994d --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cors.go @@ -0,0 +1,49 @@ +package kaipreview + +import ( + "net/http" + "slices" +) + +type CORS struct { + allowedOrigins []string +} + +func NewCORS(allowedOrigins []string) *CORS { + return &CORS{allowedOrigins: allowedOrigins} +} + +func (c *CORS) IsAllowed(origin string) bool { + return slices.Contains(c.allowedOrigins, origin) +} + +// HandlePreflight returns true when the request was a preflight OPTIONS and was handled. +// Caller should return immediately if true. Returns false for non-OPTIONS requests. +func (c *CORS) HandlePreflight(w http.ResponseWriter, r *http.Request) bool { + if r.Method != http.MethodOptions { + return false + } + origin := r.Header.Get("Origin") + if !c.IsAllowed(origin) { + http.Error(w, "origin not allowed", http.StatusForbidden) + return true + } + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "X-StorageApi-Token, Content-Type") + w.Header().Set("Access-Control-Max-Age", "600") + w.WriteHeader(http.StatusNoContent) + return true +} + +// WriteResponseHeaders sets the CORS response headers on a regular (non-preflight) response. +// Call this from the actual handler before writing the body. +func (c *CORS) WriteResponseHeaders(w http.ResponseWriter, origin string) { + if !c.IsAllowed(origin) { + return + } + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Vary", "Origin") +} diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cors_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cors_test.go new file mode 100644 index 0000000000..0b39bd77f8 --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cors_test.go @@ -0,0 +1,60 @@ +package kaipreview + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCORS_AllowedOrigin_Preflight(t *testing.T) { + t.Parallel() + cors := NewCORS([]string{"https://connection.keboola.com"}) + + r := httptest.NewRequest(http.MethodOptions, "/_proxy/kai-preview/embed-token", nil) + r.Header.Set("Origin", "https://connection.keboola.com") + r.Header.Set("Access-Control-Request-Method", "POST") + r.Header.Set("Access-Control-Request-Headers", "X-StorageApi-Token, Content-Type") + w := httptest.NewRecorder() + + handled := cors.HandlePreflight(w, r) + assert.True(t, handled) + assert.Equal(t, http.StatusNoContent, w.Code) + assert.Equal(t, "https://connection.keboola.com", w.Header().Get("Access-Control-Allow-Origin")) + assert.Equal(t, "true", w.Header().Get("Access-Control-Allow-Credentials")) + assert.Contains(t, w.Header().Get("Access-Control-Allow-Headers"), "X-StorageApi-Token") +} + +func TestCORS_DisallowedOrigin_Preflight(t *testing.T) { + t.Parallel() + cors := NewCORS([]string{"https://connection.keboola.com"}) + + r := httptest.NewRequest(http.MethodOptions, "/_proxy/kai-preview/embed-token", nil) + r.Header.Set("Origin", "https://evil.example.com") + w := httptest.NewRecorder() + + handled := cors.HandlePreflight(w, r) + assert.True(t, handled) + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin")) +} + +func TestCORS_WriteResponseHeaders(t *testing.T) { + t.Parallel() + cors := NewCORS([]string{"https://connection.keboola.com"}) + + w := httptest.NewRecorder() + cors.WriteResponseHeaders(w, "https://connection.keboola.com") + assert.Equal(t, "https://connection.keboola.com", w.Header().Get("Access-Control-Allow-Origin")) + assert.Equal(t, "true", w.Header().Get("Access-Control-Allow-Credentials")) +} + +func TestCORS_NonPreflightPassesThrough(t *testing.T) { + t.Parallel() + cors := NewCORS([]string{"https://connection.keboola.com"}) + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/embed-token", nil) + w := httptest.NewRecorder() + handled := cors.HandlePreflight(w, r) + assert.False(t, handled) +} From 7cdb3ee46122162245d481a3c62e37294d1b690e Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 17:47:17 +0200 Subject: [PATCH 10/49] feat(apps-proxy): kai-preview frame-ancestors CSP helper --- .../apphandler/authproxy/kaipreview/csp.go | 17 +++++++++++++ .../authproxy/kaipreview/csp_test.go | 25 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/csp.go create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/csp_test.go diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/csp.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/csp.go new file mode 100644 index 0000000000..432b4641ae --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/csp.go @@ -0,0 +1,17 @@ +package kaipreview + +import ( + "net/http" + "strings" +) + +// WriteFrameAncestorsCSP sets a Content-Security-Policy header with a single +// frame-ancestors directive. Pass the allowed IDE origins; pass nil/empty to +// fall back to 'none' (deny all embedding). +func WriteFrameAncestorsCSP(w http.ResponseWriter, allowedOrigins []string) { + if len(allowedOrigins) == 0 { + w.Header().Set("Content-Security-Policy", "frame-ancestors 'none'") + return + } + w.Header().Set("Content-Security-Policy", "frame-ancestors "+strings.Join(allowedOrigins, " ")) +} diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/csp_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/csp_test.go new file mode 100644 index 0000000000..a2f2ba47b8 --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/csp_test.go @@ -0,0 +1,25 @@ +package kaipreview + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWriteFrameAncestorsCSP(t *testing.T) { + t.Parallel() + w := httptest.NewRecorder() + WriteFrameAncestorsCSP(w, []string{"https://connection.keboola.com", "https://connection.eu-central-1.keboola.com"}) + assert.Equal(t, + "frame-ancestors https://connection.keboola.com https://connection.eu-central-1.keboola.com", + w.Header().Get("Content-Security-Policy"), + ) +} + +func TestWriteFrameAncestorsCSP_Empty(t *testing.T) { + t.Parallel() + w := httptest.NewRecorder() + WriteFrameAncestorsCSP(w, nil) + assert.Equal(t, "frame-ancestors 'none'", w.Header().Get("Content-Security-Policy")) +} From 79ee9e751079b472bb62fed29367e54d3b2f69fc Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 17:48:56 +0200 Subject: [PATCH 11/49] feat(apps-proxy): kai-preview iframe document-load detector --- .../authproxy/kaipreview/iframe_detect.go | 22 +++++++++++ .../kaipreview/iframe_detect_test.go | 38 +++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/iframe_detect.go create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/iframe_detect_test.go diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/iframe_detect.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/iframe_detect.go new file mode 100644 index 0000000000..ffa27bd1e3 --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/iframe_detect.go @@ -0,0 +1,22 @@ +package kaipreview + +import ( + "net/http" + "strings" +) + +// IsIframeDocumentLoad heuristically detects a top-level iframe document load by +// looking at the Sec-Fetch-Dest header and Accept header. Used as a routing +// signal (not a security gate) to decide when to serve the bootstrap shim on a +// dev-mode app with no valid session cookie. +// +// The signal is UX-only because Sec-Fetch-* is forgeable by non-browser clients. +// The bootstrap shim itself is harmless without the postMessage handshake from a +// trusted IDE origin. +func IsIframeDocumentLoad(r *http.Request) bool { + dest := r.Header.Get("Sec-Fetch-Dest") + if dest != "iframe" && dest != "frame" { + return false + } + return strings.Contains(r.Header.Get("Accept"), "text/html") +} diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/iframe_detect_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/iframe_detect_test.go new file mode 100644 index 0000000000..8c3e328a07 --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/iframe_detect_test.go @@ -0,0 +1,38 @@ +package kaipreview + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsIframeDocumentLoad(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + dest string + accept string + want bool + }{ + {"iframe-html", "iframe", "text/html,*/*;q=0.8", true}, + {"frame-html", "frame", "text/html", true}, + {"document-html", "document", "text/html", false}, // top-level navigation, not iframe + {"xhr", "empty", "application/json", false}, + {"image", "image", "image/*", false}, + {"script", "script", "*/*", false}, + {"iframe-but-json-accept", "iframe", "application/json", false}, + {"no-sec-fetch-headers", "", "text/html", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest("GET", "/some/path", nil) + if tc.dest != "" { + r.Header.Set("Sec-Fetch-Dest", tc.dest) + } + r.Header.Set("Accept", tc.accept) + assert.Equal(t, tc.want, IsIframeDocumentLoad(r)) + }) + } +} From e9447f8fb863f73c41b80e06b77d837b085e9f27 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 17:52:23 +0200 Subject: [PATCH 12/49] feat(apps-proxy): kai-preview bootstrap shim with postMessage handshake --- .../authproxy/kaipreview/bootstrap.go | 48 +++++++++++ .../authproxy/kaipreview/bootstrap_test.go | 41 ++++++++++ .../kaipreview/template/bootstrap.gohtml | 82 +++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/bootstrap.go create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/bootstrap_test.go create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/template/bootstrap.gohtml diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/bootstrap.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/bootstrap.go new file mode 100644 index 0000000000..3979684351 --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/bootstrap.go @@ -0,0 +1,48 @@ +package kaipreview + +import ( + "embed" + "encoding/json" + "html/template" + "net/http" + + "github.com/keboola/keboola-as-code/internal/pkg/utils/errors" +) + +//go:embed template/bootstrap.gohtml +var bootstrapFS embed.FS + +var bootstrapTmpl = template.Must(template.ParseFS(bootstrapFS, "template/bootstrap.gohtml")) + +type BootstrapHandler struct { + allowedIDEOrigins []string + originsJSON template.JS +} + +func NewBootstrapHandler(allowedIDEOrigins []string) *BootstrapHandler { + bs, _ := json.Marshal(allowedIDEOrigins) // []string round-trip never errors for []string + return &BootstrapHandler{ + allowedIDEOrigins: allowedIDEOrigins, + originsJSON: template.JS(bs), + } +} + +func (h *BootstrapHandler) ServeHTTPOrError(w http.ResponseWriter, r *http.Request) error { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return nil + } + WriteFrameAncestorsCSP(w, h.allowedIDEOrigins) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + + data := struct { + AllowedIDEOriginsJSON template.JS + }{ + AllowedIDEOriginsJSON: h.originsJSON, + } + if err := bootstrapTmpl.Execute(w, data); err != nil { + return errors.Errorf("kai-preview: render bootstrap shim: %w", err) + } + return nil +} diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/bootstrap_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/bootstrap_test.go new file mode 100644 index 0000000000..a2fca22624 --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/bootstrap_test.go @@ -0,0 +1,41 @@ +package kaipreview + +import ( + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBootstrapHandler_ServesHTMLWithCSP(t *testing.T) { + t.Parallel() + h := NewBootstrapHandler([]string{"https://connection.keboola.com"}) + r := httptest.NewRequest("GET", "/_proxy/kai-preview/bootstrap", nil) + w := httptest.NewRecorder() + + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, 200, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "text/html") + assert.Contains(t, w.Header().Get("Content-Security-Policy"), "frame-ancestors https://connection.keboola.com") + + body := w.Body.String() + assert.Contains(t, body, "postMessage", "shim must postMessage") + assert.Contains(t, body, "kai-preview-ready", "shim must send 'kai-preview-ready' to parent") + assert.Contains(t, body, "/_proxy/kai-preview/exchange", "shim must POST to exchange") + assert.Contains(t, body, "https://connection.keboola.com", "shim must restrict postMessage targetOrigin") + assert.NotContains(t, body, `targetOrigin: "*"`, "shim must NEVER postMessage with wildcard targetOrigin") + assert.NotContains(t, strings.ToLower(body), "innerhtml", "shim must not write user-supplied data via innerHTML") +} + +func TestBootstrapHandler_WrongMethod(t *testing.T) { + t.Parallel() + h := NewBootstrapHandler([]string{"https://connection.keboola.com"}) + r := httptest.NewRequest("POST", "/_proxy/kai-preview/bootstrap", nil) + w := httptest.NewRecorder() + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, 405, w.Code) +} diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/template/bootstrap.gohtml b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/template/bootstrap.gohtml new file mode 100644 index 0000000000..6ccb2e9029 --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/template/bootstrap.gohtml @@ -0,0 +1,82 @@ + + + + + Loading preview… + + + +
Loading preview…
+ + + From 897fad1d255d5d7f841d571c7c62266f0367dccd Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 17:59:10 +0200 Subject: [PATCH 13/49] feat(apps-proxy): kai-preview embed-token endpoint (mint handshake JWT) --- .../authproxy/kaipreview/embed_token.go | 92 +++++++++ .../authproxy/kaipreview/embed_token_test.go | 182 ++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token.go create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token_test.go diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token.go new file mode 100644 index 0000000000..b7a820080d --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token.go @@ -0,0 +1,92 @@ +package kaipreview + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/jonboulle/clockwork" + + "github.com/keboola/keboola-as-code/internal/pkg/utils/errors" +) + +// STATokenVerifier abstracts STAVerifier so tests can inject a stub without HTTP. +type STATokenVerifier interface { + Verify(ctx context.Context, token string) (*STAVerifyResult, error) +} + +// DevModeChecker tells the handler whether the current app is in dev mode. +// Backed by the apps-proxy CRD watcher (AppInfo.DevMode). +type DevModeChecker interface { + IsDevMode(appID string) bool +} + +type EmbedTokenDeps struct { + Clock clockwork.Clock + STA STATokenVerifier + DevMode DevModeChecker + CORS *CORS + HandshakeKey string + AppID string + AppProjectID string +} + +type EmbedTokenHandler struct { + deps EmbedTokenDeps +} + +func NewEmbedTokenHandler(deps EmbedTokenDeps) *EmbedTokenHandler { + return &EmbedTokenHandler{deps: deps} +} + +func (h *EmbedTokenHandler) ServeHTTPOrError(w http.ResponseWriter, r *http.Request) error { + if h.deps.CORS.HandlePreflight(w, r) { + return nil + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return nil + } + + // Origin must be allowed even on the actual request (defence in depth). + origin := r.Header.Get("Origin") + if !h.deps.CORS.IsAllowed(origin) { + http.Error(w, "origin not allowed", http.StatusForbidden) + return nil + } + + // Dev-mode gate first: pretend the endpoint doesn't exist on non-dev apps. + if !h.deps.DevMode.IsDevMode(h.deps.AppID) { + http.NotFound(w, r) + return nil + } + + staToken := r.Header.Get("X-StorageApi-Token") + if staToken == "" { + http.Error(w, "missing X-StorageApi-Token", http.StatusUnauthorized) + return nil + } + + res, err := h.deps.STA.Verify(r.Context(), staToken) + if err != nil { + // Never echo the raw STA token in the error body or logs. + http.Error(w, "STA token invalid", http.StatusUnauthorized) + return nil + } + if res.ProjectID != h.deps.AppProjectID { + http.Error(w, "app belongs to a different project", http.StatusForbidden) + return nil + } + + jwt, err := MintHandshakeJWT(h.deps.HandshakeKey, h.deps.Clock, h.deps.AppID, h.deps.AppProjectID) + if err != nil { + return errors.Errorf("kai-preview: mint handshake JWT: %w", err) + } + + h.deps.CORS.WriteResponseHeaders(w, origin) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]string{"token": jwt}) + return nil +} diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token_test.go new file mode 100644 index 0000000000..a6a95df6c0 --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token_test.go @@ -0,0 +1,182 @@ +package kaipreview + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type stubSTAVerifier struct { + projectID string + err error +} + +func (s *stubSTAVerifier) Verify(_ context.Context, _ string) (*STAVerifyResult, error) { + if s.err != nil { + return nil, s.err + } + return &STAVerifyResult{ProjectID: s.projectID}, nil +} + +type stubDevModeChecker struct{ devMode bool } + +func (s *stubDevModeChecker) IsDevMode(_ string) bool { return s.devMode } + +var errStubUnauth = &stubErr{msg: "unauthorized"} + +type stubErr struct{ msg string } + +func (e *stubErr) Error() string { return e.msg } + +func newTestEmbedHandler(staOK bool, staProject string, devMode bool) *EmbedTokenHandler { + var sta STATokenVerifier + if staOK { + sta = &stubSTAVerifier{projectID: staProject} + } else { + sta = &stubSTAVerifier{err: errStubUnauth} + } + return NewEmbedTokenHandler(EmbedTokenDeps{ + Clock: clockwork.NewFakeClock(), + STA: sta, + DevMode: &stubDevModeChecker{devMode: devMode}, + CORS: NewCORS([]string{"https://connection.keboola.com"}), + HandshakeKey: testHandshakeKey, + AppID: "app-123", + AppProjectID: "proj-456", + }) +} + +func TestEmbedTokenHandler_Success(t *testing.T) { + t.Parallel() + h := newTestEmbedHandler(true, "proj-456", true) + + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/embed-token", nil) + r.Header.Set("Origin", "https://connection.keboola.com") + r.Header.Set("X-StorageApi-Token", "valid-token") + w := httptest.NewRecorder() + + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "https://connection.keboola.com", w.Header().Get("Access-Control-Allow-Origin")) + + var body struct { + Token string `json:"token"` + } + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + require.NotEmpty(t, body.Token) +} + +func TestEmbedTokenHandler_PreflightOptions(t *testing.T) { + t.Parallel() + h := newTestEmbedHandler(true, "proj-456", true) + + r := httptest.NewRequest(http.MethodOptions, "/_proxy/kai-preview/embed-token", nil) + r.Header.Set("Origin", "https://connection.keboola.com") + r.Header.Set("Access-Control-Request-Method", "POST") + w := httptest.NewRecorder() + + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, w.Code) +} + +func TestEmbedTokenHandler_MissingSTAHeader(t *testing.T) { + t.Parallel() + h := newTestEmbedHandler(true, "proj-456", true) + + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/embed-token", nil) + r.Header.Set("Origin", "https://connection.keboola.com") + w := httptest.NewRecorder() + + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestEmbedTokenHandler_STAInvalid(t *testing.T) { + t.Parallel() + h := newTestEmbedHandler(false, "", true) + + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/embed-token", nil) + r.Header.Set("Origin", "https://connection.keboola.com") + r.Header.Set("X-StorageApi-Token", "bad-token") + w := httptest.NewRecorder() + + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestEmbedTokenHandler_WrongProject(t *testing.T) { + t.Parallel() + h := newTestEmbedHandler(true, "different-project", true) + + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/embed-token", nil) + r.Header.Set("Origin", "https://connection.keboola.com") + r.Header.Set("X-StorageApi-Token", "valid-but-wrong-project") + w := httptest.NewRecorder() + + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestEmbedTokenHandler_AppNotInDevMode(t *testing.T) { + t.Parallel() + h := newTestEmbedHandler(true, "proj-456", false) + + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/embed-token", nil) + r.Header.Set("Origin", "https://connection.keboola.com") + r.Header.Set("X-StorageApi-Token", "valid-token") + w := httptest.NewRecorder() + + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusNotFound, w.Code, "must look like the endpoint doesn't exist when app isn't in dev mode") +} + +func TestEmbedTokenHandler_DisallowedOrigin(t *testing.T) { + t.Parallel() + h := newTestEmbedHandler(true, "proj-456", true) + + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/embed-token", nil) + r.Header.Set("Origin", "https://evil.example.com") + r.Header.Set("X-StorageApi-Token", "valid-token") + w := httptest.NewRecorder() + + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestEmbedTokenHandler_WrongMethod(t *testing.T) { + t.Parallel() + h := newTestEmbedHandler(true, "proj-456", true) + r := httptest.NewRequest(http.MethodGet, "/_proxy/kai-preview/embed-token", nil) + r.Header.Set("Origin", "https://connection.keboola.com") + w := httptest.NewRecorder() + + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusMethodNotAllowed, w.Code) +} + +func TestEmbedTokenHandler_NoTokenInErrorBody(t *testing.T) { + t.Parallel() + h := newTestEmbedHandler(false, "", true) + + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/embed-token", nil) + r.Header.Set("Origin", "https://connection.keboola.com") + r.Header.Set("X-StorageApi-Token", "secret-token-do-not-leak") + w := httptest.NewRecorder() + _ = h.ServeHTTPOrError(w, r) + assert.False(t, strings.Contains(w.Body.String(), "secret-token-do-not-leak")) +} From 505f4ab79a17a0b2cae16484f47550db4f738310 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 18:03:58 +0200 Subject: [PATCH 14/49] fix(apps-proxy): emit CORS headers on kai-preview embed-token auth failures --- .../proxy/apphandler/authproxy/kaipreview/embed_token.go | 5 ++++- .../apphandler/authproxy/kaipreview/embed_token_test.go | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token.go index b7a820080d..5dc029fb96 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token.go @@ -54,6 +54,9 @@ func (h *EmbedTokenHandler) ServeHTTPOrError(w http.ResponseWriter, r *http.Requ http.Error(w, "origin not allowed", http.StatusForbidden) return nil } + // All remaining responses are to an allowed origin — emit CORS headers now so the + // SPA can read status codes and bodies of auth-failure responses, not just successes. + h.deps.CORS.WriteResponseHeaders(w, origin) // Dev-mode gate first: pretend the endpoint doesn't exist on non-dev apps. if !h.deps.DevMode.IsDevMode(h.deps.AppID) { @@ -83,7 +86,7 @@ func (h *EmbedTokenHandler) ServeHTTPOrError(w http.ResponseWriter, r *http.Requ return errors.Errorf("kai-preview: mint handshake JWT: %w", err) } - h.deps.CORS.WriteResponseHeaders(w, origin) + // CORS headers already set above. w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusOK) diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token_test.go index a6a95df6c0..06a193e040 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token_test.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token_test.go @@ -99,6 +99,8 @@ func TestEmbedTokenHandler_MissingSTAHeader(t *testing.T) { err := h.ServeHTTPOrError(w, r) require.NoError(t, err) assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, "https://connection.keboola.com", w.Header().Get("Access-Control-Allow-Origin"), + "auth-failure responses to allowed origins must include CORS headers so SPA can read status") } func TestEmbedTokenHandler_STAInvalid(t *testing.T) { @@ -113,6 +115,8 @@ func TestEmbedTokenHandler_STAInvalid(t *testing.T) { err := h.ServeHTTPOrError(w, r) require.NoError(t, err) assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, "https://connection.keboola.com", w.Header().Get("Access-Control-Allow-Origin"), + "auth-failure responses to allowed origins must include CORS headers so SPA can read status") } func TestEmbedTokenHandler_WrongProject(t *testing.T) { @@ -127,6 +131,8 @@ func TestEmbedTokenHandler_WrongProject(t *testing.T) { err := h.ServeHTTPOrError(w, r) require.NoError(t, err) assert.Equal(t, http.StatusForbidden, w.Code) + assert.Equal(t, "https://connection.keboola.com", w.Header().Get("Access-Control-Allow-Origin"), + "auth-failure responses to allowed origins must include CORS headers so SPA can read status") } func TestEmbedTokenHandler_AppNotInDevMode(t *testing.T) { @@ -155,6 +161,8 @@ func TestEmbedTokenHandler_DisallowedOrigin(t *testing.T) { err := h.ServeHTTPOrError(w, r) require.NoError(t, err) assert.Equal(t, http.StatusForbidden, w.Code) + assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin"), + "disallowed origins must NOT receive CORS headers") } func TestEmbedTokenHandler_WrongMethod(t *testing.T) { From fb97e3f54c234e5ccb4cba96f3127098a7a1195e Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 18:06:15 +0200 Subject: [PATCH 15/49] =?UTF-8?q?feat(apps-proxy):=20kai-preview=20exchang?= =?UTF-8?q?e=20endpoint=20(JWT=20=E2=86=92=20session=20cookie)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../authproxy/kaipreview/exchange.go | 70 ++++++++++ .../authproxy/kaipreview/exchange_test.go | 131 ++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/exchange.go create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/exchange_test.go diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/exchange.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/exchange.go new file mode 100644 index 0000000000..a7f6b1ccc7 --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/exchange.go @@ -0,0 +1,70 @@ +package kaipreview + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/jonboulle/clockwork" + + "github.com/keboola/keboola-as-code/internal/pkg/utils/errors" +) + +type ExchangeDeps struct { + Clock clockwork.Clock + DevMode DevModeChecker + HandshakeKey string + SessionKey string + SessionTTL time.Duration + AppID string + AppProjectID string +} + +type ExchangeHandler struct { + deps ExchangeDeps +} + +func NewExchangeHandler(deps ExchangeDeps) *ExchangeHandler { + return &ExchangeHandler{deps: deps} +} + +func (h *ExchangeHandler) ServeHTTPOrError(w http.ResponseWriter, r *http.Request) error { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return nil + } + + // Dev-mode gate: pretend the endpoint doesn't exist on non-dev apps. + if !h.deps.DevMode.IsDevMode(h.deps.AppID) { + http.NotFound(w, r) + return nil + } + + var body struct { + Token string `json:"token"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Token == "" { + http.Error(w, "invalid body", http.StatusBadRequest) + return nil + } + + claims, err := VerifyHandshakeJWT(h.deps.HandshakeKey, h.deps.Clock, body.Token) + if err != nil { + http.Error(w, "invalid handshake token", http.StatusUnauthorized) + return nil + } + if claims.AppID != h.deps.AppID || claims.ProjectID != h.deps.AppProjectID { + http.Error(w, "handshake token scope mismatch", http.StatusForbidden) + return nil + } + + sessionJWT, err := MintSessionJWT(h.deps.SessionKey, h.deps.Clock, h.deps.AppID, h.deps.AppProjectID, h.deps.SessionTTL) + if err != nil { + return errors.Errorf("kai-preview: mint session JWT: %w", err) + } + + SetSessionCookie(w, sessionJWT, h.deps.SessionTTL) + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + return nil +} diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/exchange_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/exchange_test.go new file mode 100644 index 0000000000..45b271035b --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/exchange_test.go @@ -0,0 +1,131 @@ +package kaipreview + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestExchangeHandler(devMode bool) *ExchangeHandler { + return NewExchangeHandler(ExchangeDeps{ + Clock: clockwork.NewFakeClock(), + DevMode: &stubDevModeChecker{devMode: devMode}, + HandshakeKey: testHandshakeKey, + SessionKey: testSessionKey, + SessionTTL: 4 * time.Hour, + AppID: "app-123", + AppProjectID: "proj-456", + }) +} + +func mintForTest(t *testing.T, appID, projectID string) string { + t.Helper() + clock := clockwork.NewFakeClock() + jwt, err := MintHandshakeJWT(testHandshakeKey, clock, appID, projectID) + require.NoError(t, err) + return jwt +} + +func TestExchangeHandler_Success(t *testing.T) { + t.Parallel() + h := newTestExchangeHandler(true) + jwt := mintForTest(t, "app-123", "proj-456") + + body, _ := json.Marshal(map[string]string{"token": jwt}) + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/exchange", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, w.Code) + + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + assert.Equal(t, SessionCookieName, cookies[0].Name) + assert.True(t, cookies[0].Partitioned) +} + +func TestExchangeHandler_InvalidJWT(t *testing.T) { + t.Parallel() + h := newTestExchangeHandler(true) + + body, _ := json.Marshal(map[string]string{"token": "not-a-jwt"}) + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/exchange", bytes.NewReader(body)) + w := httptest.NewRecorder() + + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestExchangeHandler_AppIDMismatch(t *testing.T) { + t.Parallel() + h := newTestExchangeHandler(true) + jwt := mintForTest(t, "different-app", "proj-456") + + body, _ := json.Marshal(map[string]string{"token": jwt}) + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/exchange", bytes.NewReader(body)) + w := httptest.NewRecorder() + + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestExchangeHandler_ProjectMismatch(t *testing.T) { + t.Parallel() + h := newTestExchangeHandler(true) + jwt := mintForTest(t, "app-123", "different-project") + + body, _ := json.Marshal(map[string]string{"token": jwt}) + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/exchange", bytes.NewReader(body)) + w := httptest.NewRecorder() + + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestExchangeHandler_DevModeOff(t *testing.T) { + t.Parallel() + h := newTestExchangeHandler(false) + jwt := mintForTest(t, "app-123", "proj-456") + + body, _ := json.Marshal(map[string]string{"token": jwt}) + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/exchange", bytes.NewReader(body)) + w := httptest.NewRecorder() + + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestExchangeHandler_WrongMethod(t *testing.T) { + t.Parallel() + h := newTestExchangeHandler(true) + r := httptest.NewRequest(http.MethodGet, "/_proxy/kai-preview/exchange", nil) + w := httptest.NewRecorder() + + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusMethodNotAllowed, w.Code) +} + +func TestExchangeHandler_EmptyBody(t *testing.T) { + t.Parallel() + h := newTestExchangeHandler(true) + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/exchange", bytes.NewReader([]byte("{}"))) + w := httptest.NewRecorder() + + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, w.Code) +} From 2aee42101953deea49e0b864a7fdcc462e00fc97 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 18:10:36 +0200 Subject: [PATCH 16/49] feat(apps-proxy): kai-preview refresh endpoint (sliding session) Add RefreshHandler that validates the session cookie JWT, mints a fresh JWT (with random jti for uniqueness), and returns 204 with the new session cookie set. CORS headers are emitted before auth checks so the SPA can read failure status codes from allowed origins. Also adds a random jti to MintSessionJWT to ensure each minted token is unique even when the clock hasn't advanced. --- .../apphandler/authproxy/kaipreview/jwt.go | 5 + .../authproxy/kaipreview/refresh.go | 78 +++++++++ .../authproxy/kaipreview/refresh_test.go | 152 ++++++++++++++++++ 3 files changed, 235 insertions(+) create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/refresh.go create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/refresh_test.go diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt.go index f9c9f8e977..71bb5647f6 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt.go @@ -87,12 +87,17 @@ func VerifyHandshakeJWT(key string, clock clockwork.Clock, raw string) (*Handsha func MintSessionJWT(key string, clock clockwork.Clock, appID, projectID string, ttl time.Duration) (string, error) { now := clock.Now() + jti, err := randomHex(16) + if err != nil { + return "", errors.Errorf("kai-preview: generate jti: %w", err) + } claims := SessionClaims{ AppID: appID, ProjectID: projectID, Purpose: purposeSession, TTL: int64(ttl.Seconds()), RegisteredClaims: jwt.RegisteredClaims{ + ID: jti, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(ttl)), }, diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/refresh.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/refresh.go new file mode 100644 index 0000000000..5ff49c1f43 --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/refresh.go @@ -0,0 +1,78 @@ +package kaipreview + +import ( + "net/http" + "time" + + "github.com/jonboulle/clockwork" + + "github.com/keboola/keboola-as-code/internal/pkg/utils/errors" +) + +type RefreshDeps struct { + Clock clockwork.Clock + DevMode DevModeChecker + SessionKey string + SessionTTL time.Duration + CORS *CORS + AppID string + AppProjectID string +} + +type RefreshHandler struct { + deps RefreshDeps +} + +func NewRefreshHandler(deps RefreshDeps) *RefreshHandler { + return &RefreshHandler{deps: deps} +} + +func (h *RefreshHandler) ServeHTTPOrError(w http.ResponseWriter, r *http.Request) error { + if h.deps.CORS.HandlePreflight(w, r) { + return nil + } + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return nil + } + + origin := r.Header.Get("Origin") + if !h.deps.CORS.IsAllowed(origin) { + http.Error(w, "origin not allowed", http.StatusForbidden) + return nil + } + // All remaining responses are to an allowed origin — emit CORS headers now so the + // SPA can read status codes and bodies of auth-failure responses, not just successes. + h.deps.CORS.WriteResponseHeaders(w, origin) + + if !h.deps.DevMode.IsDevMode(h.deps.AppID) { + http.NotFound(w, r) + return nil + } + + cookieValue := ReadSessionCookie(r) + if cookieValue == "" { + http.Error(w, "no session", http.StatusUnauthorized) + return nil + } + + claims, err := VerifySessionJWT(h.deps.SessionKey, h.deps.Clock, cookieValue) + if err != nil { + http.Error(w, "session expired", http.StatusUnauthorized) + return nil + } + if claims.AppID != h.deps.AppID || claims.ProjectID != h.deps.AppProjectID { + http.Error(w, "session scope mismatch", http.StatusForbidden) + return nil + } + + newJWT, err := MintSessionJWT(h.deps.SessionKey, h.deps.Clock, h.deps.AppID, h.deps.AppProjectID, h.deps.SessionTTL) + if err != nil { + return errors.Errorf("kai-preview: re-mint session JWT: %w", err) + } + SetSessionCookie(w, newJWT, h.deps.SessionTTL) + + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusNoContent) + return nil +} diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/refresh_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/refresh_test.go new file mode 100644 index 0000000000..027c9692d6 --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/refresh_test.go @@ -0,0 +1,152 @@ +package kaipreview + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestRefreshHandler(devMode bool) (*RefreshHandler, *clockwork.FakeClock) { + clock := clockwork.NewFakeClock() + return NewRefreshHandler(RefreshDeps{ + Clock: clock, + DevMode: &stubDevModeChecker{devMode: devMode}, + SessionKey: testSessionKey, + SessionTTL: 4 * time.Hour, + CORS: NewCORS([]string{"https://connection.keboola.com"}), + AppID: "app-123", + AppProjectID: "proj-456", + }), clock +} + +func TestRefreshHandler_Success(t *testing.T) { + t.Parallel() + h, clock := newTestRefreshHandler(true) + jwt, err := MintSessionJWT(testSessionKey, clock, "app-123", "proj-456", 4*time.Hour) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/refresh", nil) + r.Header.Set("Origin", "https://connection.keboola.com") + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: jwt}) + w := httptest.NewRecorder() + + err = h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, w.Code) + assert.Equal(t, "https://connection.keboola.com", w.Header().Get("Access-Control-Allow-Origin")) + + cookies := w.Result().Cookies() + require.Len(t, cookies, 1) + assert.Equal(t, SessionCookieName, cookies[0].Name) + assert.NotEqual(t, jwt, cookies[0].Value, "must be a fresh JWT, not the old one") +} + +func TestRefreshHandler_PreflightOptions(t *testing.T) { + t.Parallel() + h, _ := newTestRefreshHandler(true) + r := httptest.NewRequest(http.MethodOptions, "/_proxy/kai-preview/refresh", nil) + r.Header.Set("Origin", "https://connection.keboola.com") + r.Header.Set("Access-Control-Request-Method", "POST") + w := httptest.NewRecorder() + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, w.Code) +} + +func TestRefreshHandler_MissingCookie(t *testing.T) { + t.Parallel() + h, _ := newTestRefreshHandler(true) + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/refresh", nil) + r.Header.Set("Origin", "https://connection.keboola.com") + w := httptest.NewRecorder() + + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, "https://connection.keboola.com", w.Header().Get("Access-Control-Allow-Origin"), + "auth-failure responses to allowed origins must include CORS headers") +} + +func TestRefreshHandler_ExpiredCookie(t *testing.T) { + t.Parallel() + h, clock := newTestRefreshHandler(true) + jwt, err := MintSessionJWT(testSessionKey, clock, "app-123", "proj-456", 4*time.Hour) + require.NoError(t, err) + clock.Advance(5 * time.Hour) + + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/refresh", nil) + r.Header.Set("Origin", "https://connection.keboola.com") + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: jwt}) + w := httptest.NewRecorder() + + err = h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, "https://connection.keboola.com", w.Header().Get("Access-Control-Allow-Origin")) +} + +func TestRefreshHandler_DevModeOff(t *testing.T) { + t.Parallel() + h, clock := newTestRefreshHandler(false) + jwt, err := MintSessionJWT(testSessionKey, clock, "app-123", "proj-456", 4*time.Hour) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/refresh", nil) + r.Header.Set("Origin", "https://connection.keboola.com") + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: jwt}) + w := httptest.NewRecorder() + + err = h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusNotFound, w.Code) +} + +func TestRefreshHandler_ScopeMismatch(t *testing.T) { + t.Parallel() + h, clock := newTestRefreshHandler(true) + jwt, err := MintSessionJWT(testSessionKey, clock, "different-app", "proj-456", 4*time.Hour) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/refresh", nil) + r.Header.Set("Origin", "https://connection.keboola.com") + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: jwt}) + w := httptest.NewRecorder() + + err = h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, w.Code) +} + +func TestRefreshHandler_DisallowedOrigin(t *testing.T) { + t.Parallel() + h, clock := newTestRefreshHandler(true) + jwt, err := MintSessionJWT(testSessionKey, clock, "app-123", "proj-456", 4*time.Hour) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/refresh", nil) + r.Header.Set("Origin", "https://evil.example.com") + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: jwt}) + w := httptest.NewRecorder() + + err = h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, w.Code) + assert.Empty(t, w.Header().Get("Access-Control-Allow-Origin")) +} + +func TestRefreshHandler_WrongMethod(t *testing.T) { + t.Parallel() + h, _ := newTestRefreshHandler(true) + r := httptest.NewRequest(http.MethodGet, "/_proxy/kai-preview/refresh", nil) + r.Header.Set("Origin", "https://connection.keboola.com") + w := httptest.NewRecorder() + + err := h.ServeHTTPOrError(w, r) + require.NoError(t, err) + assert.Equal(t, http.StatusMethodNotAllowed, w.Code) +} From 59ed499375dbef05a913b59c2e90b6a272015fd3 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 18:15:13 +0200 Subject: [PATCH 17/49] feat(apps-proxy): kai-preview composite handler for /_proxy/kai-preview/* --- .../authproxy/kaipreview/handler.go | 90 +++++++++++++++++++ .../authproxy/kaipreview/handler_test.go | 78 ++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/handler.go create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/handler_test.go diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/handler.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/handler.go new file mode 100644 index 0000000000..939e39cd8a --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/handler.go @@ -0,0 +1,90 @@ +package kaipreview + +import ( + "net/http" + "strings" + "time" + + "github.com/jonboulle/clockwork" + + "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/config" +) + +// PathPrefix is the URL prefix all kai-preview endpoints live under. +var PathPrefix = config.InternalPrefix + "/kai-preview" + +const ( + pathEmbedToken = "/embed-token" + pathBootstrap = "/bootstrap" + pathExchange = "/exchange" + pathRefresh = "/refresh" +) + +// DevModeCheckerFunc adapts a plain function to the DevModeChecker interface. +type DevModeCheckerFunc func(appID string) bool + +// IsDevMode implements DevModeChecker. +func (f DevModeCheckerFunc) IsDevMode(appID string) bool { return f(appID) } + +type HandlerDeps struct { + Clock clockwork.Clock + STA STATokenVerifier + DevMode DevModeChecker + CORS *CORS + HandshakeKey string + SessionKey string + SessionTTL time.Duration + AllowedIDEOrigins []string + AppID string + AppProjectID string +} + +// Handler is the per-app composite handler that serves all four kai-preview +// internal endpoints. One instance per app. Routes by URL path suffix. +type Handler struct { + embedToken *EmbedTokenHandler + bootstrap *BootstrapHandler + exchange *ExchangeHandler + refresh *RefreshHandler +} + +func NewHandler(deps HandlerDeps) *Handler { + return &Handler{ + embedToken: NewEmbedTokenHandler(EmbedTokenDeps{ + Clock: deps.Clock, STA: deps.STA, DevMode: deps.DevMode, CORS: deps.CORS, + HandshakeKey: deps.HandshakeKey, AppID: deps.AppID, AppProjectID: deps.AppProjectID, + }), + bootstrap: NewBootstrapHandler(deps.AllowedIDEOrigins), + exchange: NewExchangeHandler(ExchangeDeps{ + Clock: deps.Clock, DevMode: deps.DevMode, + HandshakeKey: deps.HandshakeKey, SessionKey: deps.SessionKey, SessionTTL: deps.SessionTTL, + AppID: deps.AppID, AppProjectID: deps.AppProjectID, + }), + refresh: NewRefreshHandler(RefreshDeps{ + Clock: deps.Clock, DevMode: deps.DevMode, + SessionKey: deps.SessionKey, SessionTTL: deps.SessionTTL, CORS: deps.CORS, + AppID: deps.AppID, AppProjectID: deps.AppProjectID, + }), + } +} + +func (h *Handler) ServeHTTPOrError(w http.ResponseWriter, r *http.Request) error { + if !strings.HasPrefix(r.URL.Path, PathPrefix) { + http.NotFound(w, r) + return nil + } + sub := r.URL.Path[len(PathPrefix):] + switch sub { + case pathEmbedToken: + return h.embedToken.ServeHTTPOrError(w, r) + case pathBootstrap: + return h.bootstrap.ServeHTTPOrError(w, r) + case pathExchange: + return h.exchange.ServeHTTPOrError(w, r) + case pathRefresh: + return h.refresh.ServeHTTPOrError(w, r) + default: + http.NotFound(w, r) + return nil + } +} diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/handler_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/handler_test.go new file mode 100644 index 0000000000..575c336e52 --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/handler_test.go @@ -0,0 +1,78 @@ +package kaipreview + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestCompositeHandler(devMode bool) *Handler { + return NewHandler(HandlerDeps{ + Clock: clockwork.NewFakeClock(), + STA: &stubSTAVerifier{projectID: "proj-456"}, + DevMode: &stubDevModeChecker{devMode: devMode}, + CORS: NewCORS([]string{"https://connection.keboola.com"}), + HandshakeKey: testHandshakeKey, + SessionKey: testSessionKey, + SessionTTL: 4 * time.Hour, + AllowedIDEOrigins: []string{"https://connection.keboola.com"}, + AppID: "app-123", + AppProjectID: "proj-456", + }) +} + +func TestCompositeHandler_RoutesEmbedToken(t *testing.T) { + t.Parallel() + h := newTestCompositeHandler(true) + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/embed-token", nil) + r.Header.Set("Origin", "https://connection.keboola.com") + r.Header.Set("X-StorageApi-Token", "valid") + w := httptest.NewRecorder() + require.NoError(t, h.ServeHTTPOrError(w, r)) + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestCompositeHandler_RoutesBootstrap(t *testing.T) { + t.Parallel() + h := newTestCompositeHandler(true) + r := httptest.NewRequest(http.MethodGet, "/_proxy/kai-preview/bootstrap", nil) + w := httptest.NewRecorder() + require.NoError(t, h.ServeHTTPOrError(w, r)) + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "text/html") +} + +func TestCompositeHandler_RoutesExchange(t *testing.T) { + t.Parallel() + h := newTestCompositeHandler(true) + // Empty body — exchange should return 400 (handler reached, valid path) + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/exchange", nil) + w := httptest.NewRecorder() + require.NoError(t, h.ServeHTTPOrError(w, r)) + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestCompositeHandler_RoutesRefresh(t *testing.T) { + t.Parallel() + h := newTestCompositeHandler(true) + // No cookie — refresh should return 401 (handler reached, valid path) + r := httptest.NewRequest(http.MethodPost, "/_proxy/kai-preview/refresh", nil) + r.Header.Set("Origin", "https://connection.keboola.com") + w := httptest.NewRecorder() + require.NoError(t, h.ServeHTTPOrError(w, r)) + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestCompositeHandler_UnknownSubpath404(t *testing.T) { + t.Parallel() + h := newTestCompositeHandler(true) + r := httptest.NewRequest(http.MethodGet, "/_proxy/kai-preview/does-not-exist", nil) + w := httptest.NewRecorder() + require.NoError(t, h.ServeHTTPOrError(w, r)) + assert.Equal(t, http.StatusNotFound, w.Code) +} From 4ff5aee25c8ae7ca63db1c745bddd248bd564dde Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 18:18:01 +0200 Subject: [PATCH 18/49] feat(apps-proxy): kai-preview ValidateSessionCookie helper --- .../apphandler/authproxy/kaipreview/cookie.go | 21 +++++++ .../authproxy/kaipreview/cookie_test.go | 60 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie.go index c3e4b92401..d66a14ebe7 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie.go @@ -3,6 +3,8 @@ package kaipreview import ( "net/http" "time" + + "github.com/jonboulle/clockwork" ) const SessionCookieName = "kbc-kai-preview-session" @@ -52,3 +54,22 @@ func ReadSessionCookie(r *http.Request) string { } return c.Value } + +// ValidateSessionCookie reads the kai-preview session cookie from the request, +// verifies the signature, expiry, and (app_id, project) scope. Returns claims +// + true on success, nil + false on any failure. Stateless — relies only on the +// signing key. +func ValidateSessionCookie(r *http.Request, sessionKey string, clock clockwork.Clock, appID, projectID string) (*SessionClaims, bool) { + raw := ReadSessionCookie(r) + if raw == "" { + return nil, false + } + claims, err := VerifySessionJWT(sessionKey, clock, raw) + if err != nil { + return nil, false + } + if claims.AppID != appID || claims.ProjectID != projectID { + return nil, false + } + return claims, true +} diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie_test.go index 704be0b027..4e2e2b4117 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie_test.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie_test.go @@ -78,3 +78,63 @@ func TestClearSessionCookie_Attributes(t *testing.T) { assert.True(t, c.Partitioned) assert.Equal(t, http.SameSiteNoneMode, c.SameSite) } + +func TestValidateSessionCookie_Valid(t *testing.T) { + t.Parallel() + clock := clockwork.NewFakeClock() + jwt, err := MintSessionJWT(testSessionKey, clock, "app-123", "proj-456", 4*time.Hour) + require.NoError(t, err) + + r := httptest.NewRequest("GET", "/anything", nil) + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: jwt}) + + claims, ok := ValidateSessionCookie(r, testSessionKey, clock, "app-123", "proj-456") + assert.True(t, ok) + require.NotNil(t, claims) + assert.Equal(t, "app-123", claims.AppID) +} + +func TestValidateSessionCookie_Missing(t *testing.T) { + t.Parallel() + clock := clockwork.NewFakeClock() + r := httptest.NewRequest("GET", "/anything", nil) + _, ok := ValidateSessionCookie(r, testSessionKey, clock, "app-123", "proj-456") + assert.False(t, ok) +} + +func TestValidateSessionCookie_AppMismatch(t *testing.T) { + t.Parallel() + clock := clockwork.NewFakeClock() + jwt, err := MintSessionJWT(testSessionKey, clock, "different-app", "proj-456", 4*time.Hour) + require.NoError(t, err) + r := httptest.NewRequest("GET", "/anything", nil) + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: jwt}) + + _, ok := ValidateSessionCookie(r, testSessionKey, clock, "app-123", "proj-456") + assert.False(t, ok) +} + +func TestValidateSessionCookie_Expired(t *testing.T) { + t.Parallel() + clock := clockwork.NewFakeClock() + jwt, err := MintSessionJWT(testSessionKey, clock, "app-123", "proj-456", 4*time.Hour) + require.NoError(t, err) + clock.Advance(5 * time.Hour) + r := httptest.NewRequest("GET", "/anything", nil) + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: jwt}) + + _, ok := ValidateSessionCookie(r, testSessionKey, clock, "app-123", "proj-456") + assert.False(t, ok) +} + +func TestValidateSessionCookie_ProjectMismatch(t *testing.T) { + t.Parallel() + clock := clockwork.NewFakeClock() + jwt, err := MintSessionJWT(testSessionKey, clock, "app-123", "different-project", 4*time.Hour) + require.NoError(t, err) + r := httptest.NewRequest("GET", "/anything", nil) + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: jwt}) + + _, ok := ValidateSessionCookie(r, testSessionKey, clock, "app-123", "proj-456") + assert.False(t, ok) +} From f944fe9b528f80714c57df9855dddfca3e38f7e4 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 18:28:02 +0200 Subject: [PATCH 19/49] feat(apps-proxy): wire kai-preview into apphandler routing - Add StorageAPIURL to config.Config (required field, defaults to connection.keboola.com) so the STA verifier can be constructed. - Add clock and staVerifier to apphandler.Manager; constructed in NewManager using d.Clock() and kaipreview.NewSTAVerifier(). - Add kaiPreview field to appHandler; constructed per-app in newAppHandler with a DevModeCheckerFunc backed by the live K8s state watcher. - Insert kai-preview routing decisions in serveHTTPOrError (between the hostname-redirect and the existing internal URL routing): 1. /_proxy/kai-preview/* -> kai-preview composite handler 2. Valid session cookie -> forward to upstream, skip AuthRules (TODO T15: sliding refresh) 3. Sec-Fetch-Dest=iframe with no session -> serve bootstrap shim - Falls through to existing AuthRules when DevMode is false. - Add isDevMode() helper that re-reads live K8s cache per request. - Update mocked dependency scope to auto-populate StorageAPIURL and KaiPreview signing keys for test configurations. - Add scaffolded integration test (t.Skip) in apphandler package. - Add three real integration tests to proxy_test.go: bootstrap on dev-mode app, fall-through on non-dev-mode app, and iframe bootstrap fallback detection. --- .../pkg/service/appsproxy/config/config.go | 5 ++ .../service/appsproxy/dependencies/mocked.go | 14 +++ .../appsproxy/proxy/apphandler/apphandler.go | 49 +++++++++++ .../apphandler/kaipreview_integration_test.go | 33 +++++++ .../appsproxy/proxy/apphandler/manager.go | 17 +++- .../pkg/service/appsproxy/proxy/proxy_test.go | 85 +++++++++++++++++++ 6 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/kaipreview_integration_test.go diff --git a/internal/pkg/service/appsproxy/config/config.go b/internal/pkg/service/appsproxy/config/config.go index e557060fa8..8e9e8f90fd 100644 --- a/internal/pkg/service/appsproxy/config/config.go +++ b/internal/pkg/service/appsproxy/config/config.go @@ -24,6 +24,7 @@ type Config struct { Upstream Upstream `configKey:"-" configUsage:"Configuration options for upstream"` SandboxesAPI SandboxesAPI `configKey:"sandboxesAPI"` CsrfTokenSalt string `configKey:"csrfTokenSalt" configUsage:"Salt used for generating CSRF tokens" validate:"required" sensitive:"true"` + StorageAPIURL *url.URL `configKey:"storageApiUrl" configUsage:"Base URL of the Keboola Storage API used for STA token verification (kai-preview flow). E.g. https://connection.keboola.com" validate:"required"` KaiPreview KaiPreview `configKey:"kaiPreview" configUsage:"kai-preview iframe-auth configuration."` K8s K8s `configKey:"k8s" configUsage:"Kubernetes configuration."` E2bWebhook E2BWebhook `configKey:"e2bWebhook"` @@ -86,6 +87,10 @@ func New() Config { KaiPreview: KaiPreview{ SessionTTL: 4 * time.Hour, }, + StorageAPIURL: &url.URL{ + Scheme: "https", + Host: "connection.keboola.com", + }, } } diff --git a/internal/pkg/service/appsproxy/dependencies/mocked.go b/internal/pkg/service/appsproxy/dependencies/mocked.go index d857f0b688..5f7ca5a006 100644 --- a/internal/pkg/service/appsproxy/dependencies/mocked.go +++ b/internal/pkg/service/appsproxy/dependencies/mocked.go @@ -77,6 +77,20 @@ func newMockedServiceScope(tb testing.TB, ctx context.Context, cfg config.Config if cfg.SandboxesAPI.Token == "" { cfg.SandboxesAPI.Token = "my-token" } + if cfg.StorageAPIURL == nil { + var err error + cfg.StorageAPIURL, err = url.Parse("https://connection.keboola.com") + require.NoError(tb, err) + } + if cfg.KaiPreview.HandshakeSigningKey == "" { + cfg.KaiPreview.HandshakeSigningKey = "test-handshake-signing-key-for-mocked-scope" + } + if cfg.KaiPreview.SessionSigningKey == "" { + cfg.KaiPreview.SessionSigningKey = "test-session-signing-key-for-mocked-scope" + } + if len(cfg.KaiPreview.AllowedIDEOrigins) == 0 { + cfg.KaiPreview.AllowedIDEOrigins = []string{"https://connection.keboola.com"} + } if cfg.K8s.AppsNamespace == "" { cfg.K8s.AppsNamespace = "keboola" } diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/apphandler.go b/internal/pkg/service/appsproxy/proxy/apphandler/apphandler.go index 5c2ec57699..e5bdf633b0 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/apphandler.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/apphandler.go @@ -3,6 +3,7 @@ package apphandler import ( + "context" "net/http" "net/url" "strings" @@ -13,6 +14,7 @@ import ( "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/config" "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/dataapps/api" "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/dataapps/auth/provider" + "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview" "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/selector" "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler/chain" "github.com/keboola/keboola-as-code/internal/pkg/service/common/ctxattr" @@ -29,11 +31,18 @@ type appHandler struct { upstream chain.Handler allAuthHandlers chain.Handler authHandlerPerRule map[ruleIndex]chain.Handler + kaiPreview *kaipreview.Handler } type ruleIndex int func newAppHandler(manager *Manager, app api.AppConfig, appUpstream chain.Handler, authHandlers map[provider.ID]selector.Handler) (http.Handler, error) { + // DevModeChecker is backed by the live K8s state watcher: re-evaluates on every request. + devModeChecker := kaipreview.DevModeCheckerFunc(func(appID string) bool { + info, ok := manager.upstreamManager.AppInfo(context.Background(), api.AppID(appID)) + return ok && info.DevMode + }) + handler := &appHandler{ manager: manager, app: app, @@ -41,6 +50,18 @@ func newAppHandler(manager *Manager, app api.AppConfig, appUpstream chain.Handle attrs: app.Telemetry(), upstream: appUpstream, authHandlerPerRule: make(map[ruleIndex]chain.Handler), + kaiPreview: kaipreview.NewHandler(kaipreview.HandlerDeps{ + Clock: manager.clock, + STA: manager.staVerifier, + DevMode: devModeChecker, + CORS: kaipreview.NewCORS(manager.config.KaiPreview.AllowedIDEOrigins), + HandshakeKey: manager.config.KaiPreview.HandshakeSigningKey, + SessionKey: manager.config.KaiPreview.SessionSigningKey, + SessionTTL: manager.config.KaiPreview.SessionTTL, + AllowedIDEOrigins: manager.config.KaiPreview.AllowedIDEOrigins, + AppID: string(app.ID), + AppProjectID: app.ProjectID, + }), } // Create handler with all auth handlers, to route internal URLs @@ -146,6 +167,26 @@ func (h *appHandler) serveHTTPOrError(w http.ResponseWriter, req *http.Request) return nil } + // kai-preview: dev-mode iframe-auth path. + // (routing decision documented in spec § "apps-proxy: routing decision for dev-mode apps") + if h.isDevMode(req.Context()) { + // 1. /_proxy/kai-preview/* routes go to the kai-preview composite handler. + if strings.HasPrefix(req.URL.Path, kaipreview.PathPrefix) { + return h.kaiPreview.ServeHTTPOrError(w, req) + } + // 2. Valid session cookie → forward to upstream, skip AuthRules. + if _, ok := kaipreview.ValidateSessionCookie(req, h.manager.config.KaiPreview.SessionSigningKey, h.manager.clock, string(h.app.ID), h.app.ProjectID); ok { + // TODO(T15): sliding refresh — re-mint when claims.NeedsRefresh(now) is true. + return h.upstream.ServeHTTPOrError(w, req) + } + // 3. Iframe document load on a dev-mode app with no session → serve bootstrap shim. + if kaipreview.IsIframeDocumentLoad(req) { + bootstrapReq := req.Clone(req.Context()) + bootstrapReq.URL.Path = kaipreview.PathPrefix + "/bootstrap" + return h.kaiPreview.ServeHTTPOrError(w, bootstrapReq) + } + } + // Route internal URLs if there is at least one auth handler if strings.HasPrefix(req.URL.Path, config.InternalPrefix) && h.allAuthHandlers != nil { return h.allAuthHandlers.ServeHTTPOrError(w, req) @@ -173,3 +214,11 @@ func (h *appHandler) serveRule(w http.ResponseWriter, req *http.Request, index r // Serve the request without authentication return h.upstream.ServeHTTPOrError(w, req) } + +// isDevMode reports whether this app currently has DevMode enabled. +// It reads from the live K8s state cache so toggling DevMode on the App CRD takes +// effect on the next request without requiring handler recreation. +func (h *appHandler) isDevMode(ctx context.Context) bool { + info, ok := h.manager.upstreamManager.AppInfo(ctx, h.app.ID) + return ok && info.DevMode +} diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/kaipreview_integration_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/kaipreview_integration_test.go new file mode 100644 index 0000000000..005a8c698e --- /dev/null +++ b/internal/pkg/service/appsproxy/proxy/apphandler/kaipreview_integration_test.go @@ -0,0 +1,33 @@ +package apphandler_test + +import ( + "testing" + + "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview" +) + +// TestApphandler_KaiPreview_FullFlow is a scaffolded end-to-end integration test +// for the kai-preview routing decisions wired into serveHTTPOrError. +// +// The full harness requires the complete proxy stack (Service scope, fake K8s client, +// test HTTP server) which lives in proxy_test.go. The test scenarios below should be +// migrated to proxy_test.go once the STA mock server is available alongside the test +// app server (Task 16 / T15 follow-up). +// +// Scenarios to implement: +// 1. Dev-mode app: GET /_proxy/kai-preview/bootstrap → 200 with shim HTML. +// 2. Dev-mode app: GET /_proxy/kai-preview/embed-token (OPTIONS preflight, allowed origin) → 204. +// 3. Dev-mode app: GET / with valid session cookie → upstream (no OAuth redirect). +// 4. Dev-mode app: GET / without cookie, Sec-Fetch-Dest=iframe, Accept=text/html → bootstrap shim (200). +// 5. Dev-mode app: GET / without cookie, Sec-Fetch-Dest=document → falls through to AuthRules. +// 6. Non-dev-mode app: GET /_proxy/kai-preview/bootstrap → 404 (dev-mode gate inside kaipreview handler). +// 7. Flip DevMode=false on live app: all /_proxy/kai-preview/* return 404; stale cookie → AuthRules. +// +// See proxy_test.go testCase harness and the "devmode" app fixture for the integration pattern. +func TestApphandler_KaiPreview_FullFlow(t *testing.T) { + t.Parallel() + t.Skip("Scaffolded — fill in alongside the full-flow test in T15/T16 once STA mock server is available. See proxy_test.go for the test harness pattern and the 'devmode' app fixture.") + + // Satisfy the import so the file compiles cleanly. + _ = kaipreview.SessionCookieName +} diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/manager.go b/internal/pkg/service/appsproxy/proxy/apphandler/manager.go index 9359ead14d..7839bdecd8 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/manager.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/manager.go @@ -6,12 +6,16 @@ import ( "encoding/hex" "net/http" "sync" + "time" + + "github.com/jonboulle/clockwork" "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/config" "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/dataapps/api" "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/dataapps/appconfig" "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/dataapps/k8sapp" "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler/authproxy" + "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview" "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler/upstream" "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/pagewriter" "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/syncmap" @@ -29,6 +33,8 @@ type Manager struct { authProxyManager *authproxy.Manager pageWriter *pagewriter.Writer handlers *syncmap.SyncMap[api.AppID, appHandlerWrapper] + clock clockwork.Clock + staVerifier kaipreview.STATokenVerifier } type appHandlerWrapper struct { @@ -39,6 +45,7 @@ type appHandlerWrapper struct { } type dependencies interface { + Clock() clockwork.Clock Config() config.Config Telemetry() telemetry.Telemetry PageWriter() *pagewriter.Writer @@ -48,8 +55,14 @@ type dependencies interface { } func NewManager(d dependencies) *Manager { + cfg := d.Config() + var storageAPIURL string + if cfg.StorageAPIURL != nil { + storageAPIURL = cfg.StorageAPIURL.String() + } + staHTTPClient := &http.Client{Timeout: 5 * time.Second} return &Manager{ - config: d.Config(), + config: cfg, telemetry: d.Telemetry(), configLoader: d.AppConfigLoader(), upstreamManager: d.UpstreamManager(), @@ -58,6 +71,8 @@ func NewManager(d dependencies) *Manager { handlers: syncmap.New[api.AppID, appHandlerWrapper](func(api.AppID) *appHandlerWrapper { return &appHandlerWrapper{lock: &sync.Mutex{}} }), + clock: d.Clock(), + staVerifier: kaipreview.NewSTAVerifier(storageAPIURL, staHTTPClient), } } diff --git a/internal/pkg/service/appsproxy/proxy/proxy_test.go b/internal/pkg/service/appsproxy/proxy/proxy_test.go index 9c6ef20c03..9e690bb021 100644 --- a/internal/pkg/service/appsproxy/proxy/proxy_test.go +++ b/internal/pkg/service/appsproxy/proxy/proxy_test.go @@ -2429,6 +2429,91 @@ func TestAppProxyRouter(t *testing.T) { }, expectedNotifications: map[string]int{}, }, + { + // kai-preview: GET /_proxy/kai-preview/bootstrap on a dev-mode app (Running) → 200 with HTML shim. + name: "kai-preview-bootstrap-dev-mode-running", + setupK8s: func(t *testing.T, fakeClient *k8sfake.FakeDynamicClient, watcher *k8sapp.StateWatcher) { + patch := []byte(`{"spec":{"devMode":{"enabled":true}},"status":{"currentState":"Running"}}`) + _, err := fakeClient.Resource(k8sapp.AppGVR()).Namespace("keboola").Patch( + t.Context(), "app-devmode", k8stypes.MergePatchType, patch, metav1.PatchOptions{}, + ) + require.NoError(t, err) + require.Eventually(t, func() bool { + info, ok := watcher.GetState(t.Context(), api.AppID("devmode")) + return ok && info.DevMode && info.ActualState == k8sapp.AppActualStateRunning + }, 5*time.Second, 50*time.Millisecond) + }, + run: func(t *testing.T, client *http.Client, m []*mockoidc.MockOIDC, appServer *testutil.AppServer, service *testutil.DataAppsAPI, fakeClient *k8sfake.FakeDynamicClient, watcher *k8sapp.StateWatcher) { + request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://dev-devmode.hub.keboola.local/_proxy/kai-preview/bootstrap", nil) + require.NoError(t, err) + response, err := client.Do(request) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + // The bootstrap shim is an HTML page with postMessage logic. + assert.Contains(t, response.Header.Get("Content-Type"), "text/html") + // The bootstrap shim references the exchange path. + assert.Contains(t, string(body), "/_proxy/kai-preview/exchange") + }, + expectedNotifications: map[string]int{}, + }, + { + // kai-preview: GET /_proxy/kai-preview/bootstrap on a non-dev-mode app falls through to + // AuthRules. The "devmode" app has AuthRequired=false, so the upstream is reached — the + // kai-preview bootstrap shim is NOT served. + name: "kai-preview-bootstrap-non-dev-mode", + run: func(t *testing.T, client *http.Client, m []*mockoidc.MockOIDC, appServer *testutil.AppServer, service *testutil.DataAppsAPI, fakeClient *k8sfake.FakeDynamicClient, watcher *k8sapp.StateWatcher) { + // "devmode" app has DevMode=false by default (makeDefaultK8sObjects does not set devMode). + request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://dev-devmode.hub.keboola.local/_proxy/kai-preview/bootstrap", nil) + require.NoError(t, err) + response, err := client.Do(request) + require.NoError(t, err) + // With DevMode=false the request falls through to the AuthRules path. + // The "devmode" app is public (AuthRequired=false), so the upstream responds directly. + require.Equal(t, http.StatusOK, response.StatusCode) + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + // Upstream app returns its response; the bootstrap shim is NOT served. + assert.Equal(t, "Hello, client", string(body)) + }, + expectedNotifications: map[string]int{ + "devmode": 1, + }, + }, + { + // kai-preview: GET / with Sec-Fetch-Dest=iframe on a dev-mode Running app and no session cookie + // → proxy serves the bootstrap shim (iframe document load detection). + name: "kai-preview-iframe-bootstrap-fallback", + setupK8s: func(t *testing.T, fakeClient *k8sfake.FakeDynamicClient, watcher *k8sapp.StateWatcher) { + patch := []byte(`{"spec":{"devMode":{"enabled":true}},"status":{"currentState":"Running"}}`) + _, err := fakeClient.Resource(k8sapp.AppGVR()).Namespace("keboola").Patch( + t.Context(), "app-devmode", k8stypes.MergePatchType, patch, metav1.PatchOptions{}, + ) + require.NoError(t, err) + require.Eventually(t, func() bool { + info, ok := watcher.GetState(t.Context(), api.AppID("devmode")) + return ok && info.DevMode && info.ActualState == k8sapp.AppActualStateRunning + }, 5*time.Second, 50*time.Millisecond) + }, + run: func(t *testing.T, client *http.Client, m []*mockoidc.MockOIDC, appServer *testutil.AppServer, service *testutil.DataAppsAPI, fakeClient *k8sfake.FakeDynamicClient, watcher *k8sapp.StateWatcher) { + request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://dev-devmode.hub.keboola.local/", nil) + require.NoError(t, err) + // Simulate an iframe document load (Sec-Fetch-Dest=iframe + Accept=text/html). + request.Header.Set("Sec-Fetch-Dest", "iframe") + request.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + response, err := client.Do(request) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + // Should be the bootstrap shim HTML, not the upstream app response. + assert.Contains(t, response.Header.Get("Content-Type"), "text/html") + assert.NotEqual(t, "Hello, client", strings.TrimSpace(string(body))) + assert.NotEmpty(t, string(body)) + }, + expectedNotifications: map[string]int{}, + }, } publicAppTestCaseFactory := func(method string) testCase { From acfdcdbf17717801c82df3eca77965a727d2d7f3 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 18:40:00 +0200 Subject: [PATCH 20/49] fix(apps-proxy): thread context through DevModeChecker; assert StorageAPIURL at startup Extend DevModeChecker.IsDevMode to accept a context.Context so that request trace IDs propagate through to AppInfo log lines instead of being dropped via context.Background(). Update DevModeCheckerFunc, all three endpoint handlers (embed_token, exchange, refresh), their test stubs, and the apphandler closure. Replace the StorageAPIURL nil-guard in manager.NewManager with an explicit panic so misconfiguration surfaces at startup rather than silently producing an empty URL. Delete the scaffolded kaipreview_integration_test.go; real integration tests belong in proxy_test.go alongside the existing kai-preview fixtures. --- .../appsproxy/proxy/apphandler/apphandler.go | 4 +-- .../authproxy/kaipreview/embed_token.go | 4 +-- .../authproxy/kaipreview/embed_token_test.go | 2 +- .../authproxy/kaipreview/exchange.go | 2 +- .../authproxy/kaipreview/handler.go | 5 +-- .../authproxy/kaipreview/refresh.go | 2 +- .../apphandler/kaipreview_integration_test.go | 33 ------------------- .../appsproxy/proxy/apphandler/manager.go | 6 ++-- 8 files changed, 13 insertions(+), 45 deletions(-) delete mode 100644 internal/pkg/service/appsproxy/proxy/apphandler/kaipreview_integration_test.go diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/apphandler.go b/internal/pkg/service/appsproxy/proxy/apphandler/apphandler.go index e5bdf633b0..11879c8159 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/apphandler.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/apphandler.go @@ -38,8 +38,8 @@ type ruleIndex int func newAppHandler(manager *Manager, app api.AppConfig, appUpstream chain.Handler, authHandlers map[provider.ID]selector.Handler) (http.Handler, error) { // DevModeChecker is backed by the live K8s state watcher: re-evaluates on every request. - devModeChecker := kaipreview.DevModeCheckerFunc(func(appID string) bool { - info, ok := manager.upstreamManager.AppInfo(context.Background(), api.AppID(appID)) + devModeChecker := kaipreview.DevModeCheckerFunc(func(ctx context.Context, appID string) bool { + info, ok := manager.upstreamManager.AppInfo(ctx, api.AppID(appID)) return ok && info.DevMode }) diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token.go index 5dc029fb96..585f515822 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token.go @@ -18,7 +18,7 @@ type STATokenVerifier interface { // DevModeChecker tells the handler whether the current app is in dev mode. // Backed by the apps-proxy CRD watcher (AppInfo.DevMode). type DevModeChecker interface { - IsDevMode(appID string) bool + IsDevMode(ctx context.Context, appID string) bool } type EmbedTokenDeps struct { @@ -59,7 +59,7 @@ func (h *EmbedTokenHandler) ServeHTTPOrError(w http.ResponseWriter, r *http.Requ h.deps.CORS.WriteResponseHeaders(w, origin) // Dev-mode gate first: pretend the endpoint doesn't exist on non-dev apps. - if !h.deps.DevMode.IsDevMode(h.deps.AppID) { + if !h.deps.DevMode.IsDevMode(r.Context(), h.deps.AppID) { http.NotFound(w, r) return nil } diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token_test.go index 06a193e040..65d4a7a348 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token_test.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token_test.go @@ -27,7 +27,7 @@ func (s *stubSTAVerifier) Verify(_ context.Context, _ string) (*STAVerifyResult, type stubDevModeChecker struct{ devMode bool } -func (s *stubDevModeChecker) IsDevMode(_ string) bool { return s.devMode } +func (s *stubDevModeChecker) IsDevMode(_ context.Context, _ string) bool { return s.devMode } var errStubUnauth = &stubErr{msg: "unauthorized"} diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/exchange.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/exchange.go index a7f6b1ccc7..2c62552a63 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/exchange.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/exchange.go @@ -35,7 +35,7 @@ func (h *ExchangeHandler) ServeHTTPOrError(w http.ResponseWriter, r *http.Reques } // Dev-mode gate: pretend the endpoint doesn't exist on non-dev apps. - if !h.deps.DevMode.IsDevMode(h.deps.AppID) { + if !h.deps.DevMode.IsDevMode(r.Context(), h.deps.AppID) { http.NotFound(w, r) return nil } diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/handler.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/handler.go index 939e39cd8a..a02cba9027 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/handler.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/handler.go @@ -1,6 +1,7 @@ package kaipreview import ( + "context" "net/http" "strings" "time" @@ -21,10 +22,10 @@ const ( ) // DevModeCheckerFunc adapts a plain function to the DevModeChecker interface. -type DevModeCheckerFunc func(appID string) bool +type DevModeCheckerFunc func(ctx context.Context, appID string) bool // IsDevMode implements DevModeChecker. -func (f DevModeCheckerFunc) IsDevMode(appID string) bool { return f(appID) } +func (f DevModeCheckerFunc) IsDevMode(ctx context.Context, appID string) bool { return f(ctx, appID) } type HandlerDeps struct { Clock clockwork.Clock diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/refresh.go b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/refresh.go index 5ff49c1f43..8305e3573c 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/refresh.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/refresh.go @@ -45,7 +45,7 @@ func (h *RefreshHandler) ServeHTTPOrError(w http.ResponseWriter, r *http.Request // SPA can read status codes and bodies of auth-failure responses, not just successes. h.deps.CORS.WriteResponseHeaders(w, origin) - if !h.deps.DevMode.IsDevMode(h.deps.AppID) { + if !h.deps.DevMode.IsDevMode(r.Context(), h.deps.AppID) { http.NotFound(w, r) return nil } diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/kaipreview_integration_test.go b/internal/pkg/service/appsproxy/proxy/apphandler/kaipreview_integration_test.go deleted file mode 100644 index 005a8c698e..0000000000 --- a/internal/pkg/service/appsproxy/proxy/apphandler/kaipreview_integration_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package apphandler_test - -import ( - "testing" - - "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview" -) - -// TestApphandler_KaiPreview_FullFlow is a scaffolded end-to-end integration test -// for the kai-preview routing decisions wired into serveHTTPOrError. -// -// The full harness requires the complete proxy stack (Service scope, fake K8s client, -// test HTTP server) which lives in proxy_test.go. The test scenarios below should be -// migrated to proxy_test.go once the STA mock server is available alongside the test -// app server (Task 16 / T15 follow-up). -// -// Scenarios to implement: -// 1. Dev-mode app: GET /_proxy/kai-preview/bootstrap → 200 with shim HTML. -// 2. Dev-mode app: GET /_proxy/kai-preview/embed-token (OPTIONS preflight, allowed origin) → 204. -// 3. Dev-mode app: GET / with valid session cookie → upstream (no OAuth redirect). -// 4. Dev-mode app: GET / without cookie, Sec-Fetch-Dest=iframe, Accept=text/html → bootstrap shim (200). -// 5. Dev-mode app: GET / without cookie, Sec-Fetch-Dest=document → falls through to AuthRules. -// 6. Non-dev-mode app: GET /_proxy/kai-preview/bootstrap → 404 (dev-mode gate inside kaipreview handler). -// 7. Flip DevMode=false on live app: all /_proxy/kai-preview/* return 404; stale cookie → AuthRules. -// -// See proxy_test.go testCase harness and the "devmode" app fixture for the integration pattern. -func TestApphandler_KaiPreview_FullFlow(t *testing.T) { - t.Parallel() - t.Skip("Scaffolded — fill in alongside the full-flow test in T15/T16 once STA mock server is available. See proxy_test.go for the test harness pattern and the 'devmode' app fixture.") - - // Satisfy the import so the file compiles cleanly. - _ = kaipreview.SessionCookieName -} diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/manager.go b/internal/pkg/service/appsproxy/proxy/apphandler/manager.go index 7839bdecd8..3898efe1af 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/manager.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/manager.go @@ -56,10 +56,10 @@ type dependencies interface { func NewManager(d dependencies) *Manager { cfg := d.Config() - var storageAPIURL string - if cfg.StorageAPIURL != nil { - storageAPIURL = cfg.StorageAPIURL.String() + if cfg.StorageAPIURL == nil { + panic("appsproxy: StorageAPIURL is required for kai-preview STA verification") } + storageAPIURL := cfg.StorageAPIURL.String() staHTTPClient := &http.Client{Timeout: 5 * time.Second} return &Manager{ config: cfg, From 73a66180464f3cd4933362f875b907035f3671d2 Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 18:47:08 +0200 Subject: [PATCH 21/49] feat(apps-proxy): kai-preview sliding session refresh on midpoint When a request arrives with a valid kai-preview session cookie whose age has passed the TTL midpoint (NeedsRefresh == true), the proxy mints a fresh JWT and emits Set-Cookie on the response before forwarding to upstream. Replaces the TODO(T15) stub added in T14. Adds TestKaiPreviewSlidingRefresh: a focused regression test that injects a FakeClock, asserts no Set-Cookie before midpoint (t+1h / 4h TTL), and confirms Set-Cookie with a valid fresh JWT appears after midpoint (t+3h). --- .../appsproxy/proxy/apphandler/apphandler.go | 24 ++- .../pkg/service/appsproxy/proxy/proxy_test.go | 148 ++++++++++++++++++ 2 files changed, 169 insertions(+), 3 deletions(-) diff --git a/internal/pkg/service/appsproxy/proxy/apphandler/apphandler.go b/internal/pkg/service/appsproxy/proxy/apphandler/apphandler.go index 11879c8159..171308dfbc 100644 --- a/internal/pkg/service/appsproxy/proxy/apphandler/apphandler.go +++ b/internal/pkg/service/appsproxy/proxy/apphandler/apphandler.go @@ -174,9 +174,27 @@ func (h *appHandler) serveHTTPOrError(w http.ResponseWriter, req *http.Request) if strings.HasPrefix(req.URL.Path, kaipreview.PathPrefix) { return h.kaiPreview.ServeHTTPOrError(w, req) } - // 2. Valid session cookie → forward to upstream, skip AuthRules. - if _, ok := kaipreview.ValidateSessionCookie(req, h.manager.config.KaiPreview.SessionSigningKey, h.manager.clock, string(h.app.ID), h.app.ProjectID); ok { - // TODO(T15): sliding refresh — re-mint when claims.NeedsRefresh(now) is true. + // 2. Valid session cookie → forward to upstream (skip AuthRules), with sliding refresh. + if claims, ok := kaipreview.ValidateSessionCookie( + req, + h.manager.config.KaiPreview.SessionSigningKey, + h.manager.clock, + string(h.app.ID), + h.app.ProjectID, + ); ok { + if claims.NeedsRefresh(h.manager.clock.Now()) { + newJWT, err := kaipreview.MintSessionJWT( + h.manager.config.KaiPreview.SessionSigningKey, + h.manager.clock, + string(h.app.ID), + h.app.ProjectID, + h.manager.config.KaiPreview.SessionTTL, + ) + if err == nil { + kaipreview.SetSessionCookie(w, newJWT, h.manager.config.KaiPreview.SessionTTL) + } + // If mint fails, just forward without refresh — the existing cookie is still valid. + } return h.upstream.ServeHTTPOrError(w, req) } // 3. Iframe document load on a dev-mode app with no session → serve bootstrap shim. diff --git a/internal/pkg/service/appsproxy/proxy/proxy_test.go b/internal/pkg/service/appsproxy/proxy/proxy_test.go index 9e690bb021..ead3d7eb30 100644 --- a/internal/pkg/service/appsproxy/proxy/proxy_test.go +++ b/internal/pkg/service/appsproxy/proxy/proxy_test.go @@ -22,6 +22,7 @@ import ( "github.com/coder/websocket" "github.com/coder/websocket/wsjson" + "github.com/jonboulle/clockwork" "github.com/keboola/go-utils/pkg/wildcards" "github.com/oauth2-proxy/mockoidc" "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" @@ -47,6 +48,7 @@ import ( "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/dataapps/k8sapp" proxyDependencies "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/dependencies" "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy" + "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview" "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/apphandler/authproxy/oauthproxy/logging" "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/pagewriter" "github.com/keboola/keboola-as-code/internal/pkg/service/appsproxy/proxy/testutil" @@ -3156,6 +3158,152 @@ func registerDefaultK8sApps(t *testing.T, watcher *k8sapp.StateWatcher) { require.True(t, watcher.WaitForCacheSync(t.Context()), "App CRD informer cache sync timed out") } +// TestKaiPreviewSlidingRefresh is a focused regression test for the sliding-refresh path in +// apphandler.serveHTTPOrError. It boots a dev-mode app via the standard harness but injects a +// FakeClock so that time can be advanced deterministically. +// +// - At t+1h (before midpoint of 4h TTL) → no Set-Cookie on the response. +// - At t+3h (past midpoint) → Set-Cookie: kbc-kai-preview-session=… appears and the new JWT +// validates as fresh (NeedsRefresh==false immediately after issuance). +func TestKaiPreviewSlidingRefresh(t *testing.T) { + t.Parallel() + ctx := t.Context() + + const sessionTTL = 4 * time.Hour + + fakeClock := clockwork.NewFakeClock() + + // Create testing apps API and upstream. + tmpDir := t.TempDir() + pm, _ := server.NewPortManager(t, tmpDir, "appsproxy") + appsAPI := testutil.StartDataAppsAPI(t, pm) + t.Cleanup(func() { appsAPI.Close() }) + appServer := testutil.StartAppServer(t, pm) + t.Cleanup(func() { appServer.Close() }) + + providers := testAuthProviders(t, pm) + appURL := testutil.AppServerURL(t, appServer) + apps := testDataApps(appURL, providers) + appsAPI.Register(apps) + + // Build the service scope with an injected FakeClock so the proxy sees our + // controlled notion of "now". + secret := make([]byte, 32) + _, err := rand.Read(secret) + require.NoError(t, err) + csrfSecret := make([]byte, 32) + _, err = rand.Read(csrfSecret) + require.NoError(t, err) + + cfg := config.New() + cfg.API.PublicURL, _ = url.Parse("https://hub.keboola.local") + cfg.CookieSecretSalt = string(secret) + cfg.CsrfTokenSalt = string(csrfSecret) + cfg.SandboxesAPI.URL = appsAPI.URL + cfg.KaiPreview.SessionTTL = sessionTTL + + d, mocked := proxyDependencies.NewMockedServiceScopeWithK8sObjects( + t, ctx, cfg, + makeDefaultK8sObjects(apps, appURL.String()), + dependencies.WithRealHTTPClient(), + dependencies.WithClock(fakeClock), + ) + defer func() { + d.Process().Shutdown(ctx, errors.New("bye bye")) + d.Process().WaitForShutdown() + }() + + // Wire logging the same way as createProxyHandler. + loggerWriter := logging.NewLoggerWriter(d.Logger(), "info") + logger.SetOutput(loggerWriter) + logger.SetErrOutput(loggerWriter) + handler := proxy.NewHandler(ctx, d) + + // Boot a TLS proxy server. + port := pm.GetFreePort() + var lc net.ListenConfig + l, err := lc.Listen(ctx, "tcp", fmt.Sprintf("127.0.0.1:%d", port)) + require.NoError(t, err) + proxySrv := &httptest.Server{ + Listener: l, + Config: &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second, ErrorLog: log.NewStdErrorLogger(d.Logger())}, + } + proxySrv.StartTLS() + t.Cleanup(func() { proxySrv.Close() }) + + proxyURL, err := url.Parse(proxySrv.URL) + require.NoError(t, err) + client := createHTTPClient(t, proxyURL) + + // Wait for K8s informer to sync, then enable DevMode=Running for the "devmode" app. + registerDefaultK8sApps(t, d.AppStateWatcher()) + patch := []byte(`{"spec":{"devMode":{"enabled":true}},"status":{"currentState":"Running"}}`) + _, err = mocked.TestFakeK8sClient().Resource(k8sapp.AppGVR()).Namespace("keboola").Patch( + ctx, "app-devmode", k8stypes.MergePatchType, patch, metav1.PatchOptions{}, + ) + require.NoError(t, err) + require.Eventually(t, func() bool { + info, ok := d.AppStateWatcher().GetState(ctx, api.AppID("devmode")) + return ok && info.DevMode && info.ActualState == k8sapp.AppActualStateRunning + }, 5*time.Second, 50*time.Millisecond) + + // Retrieve the session signing key that the mocked scope auto-filled. + sessionKey := mocked.TestConfig().KaiPreview.SessionSigningKey + + // Mint a session JWT anchored at FakeClock's current time (t=0). + rawJWT, err := kaipreview.MintSessionJWT(sessionKey, fakeClock, "devmode", "123", sessionTTL) + require.NoError(t, err) + + sendRequest := func(t *testing.T) *http.Response { + t.Helper() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://dev-devmode.hub.keboola.local/", nil) + require.NoError(t, err) + req.AddCookie(&http.Cookie{Name: kaipreview.SessionCookieName, Value: rawJWT}) + resp, err := client.Do(req) + require.NoError(t, err) + return resp + } + + // --- t+1h: before midpoint — no refresh expected --- + fakeClock.Advance(1 * time.Hour) + resp := sendRequest(t) + require.Equal(t, http.StatusOK, resp.StatusCode) + _, _ = io.ReadAll(resp.Body) + resp.Body.Close() + + var setCookieHeader string + for _, c := range resp.Cookies() { + if c.Name == kaipreview.SessionCookieName { + setCookieHeader = c.Value + } + } + assert.Empty(t, setCookieHeader, "expected no Set-Cookie at t+1h (before midpoint)") + + // --- t+3h: past midpoint (3h > 4h/2) — refresh expected --- + fakeClock.Advance(2 * time.Hour) // total 3h elapsed + resp = sendRequest(t) + require.Equal(t, http.StatusOK, resp.StatusCode) + _, _ = io.ReadAll(resp.Body) + resp.Body.Close() + + var refreshedJWT string + for _, c := range resp.Cookies() { + if c.Name == kaipreview.SessionCookieName { + refreshedJWT = c.Value + } + } + require.NotEmpty(t, refreshedJWT, "expected Set-Cookie with refreshed JWT at t+3h") + + // Verify the new JWT is valid and immediately fresh (NeedsRefresh should be false right now). + newClaims, err := kaipreview.VerifySessionJWT(sessionKey, fakeClock, refreshedJWT) + require.NoError(t, err) + assert.Equal(t, "devmode", newClaims.AppID) + assert.Equal(t, "123", newClaims.ProjectID) + assert.False(t, newClaims.NeedsRefresh(fakeClock.Now()), "newly minted JWT should not need refresh immediately") + + assert.Empty(t, mocked.DebugLogger().ErrorMessages()) +} + // makeDefaultK8sObjects converts a slice of app configs into K8s unstructured App CRD objects // with Running state and the given upstream URL. Pass the result to NewMockedServiceScopeWithK8sObjects // so the fake client is pre-populated before the informer starts. From 5142e6cacdfdb4c039b10b348d903feebe280dcf Mon Sep 17 00:00:00 2001 From: Pepa Martinec Date: Thu, 14 May 2026 18:51:53 +0200 Subject: [PATCH 22/49] docs(apps-proxy): kai-preview operator overview + smoke-test runbook Add operator-facing documentation for the kai-preview iframe-auth flow, covering endpoint reference, config keys, routing decision tree, multi-replica stateless behavior, and a step-by-step smoke-test runbook (Tasks 16 + 17). --- docs/apps-proxy/kai-preview.md | 343 +++++++++++++++++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 docs/apps-proxy/kai-preview.md diff --git a/docs/apps-proxy/kai-preview.md b/docs/apps-proxy/kai-preview.md new file mode 100644 index 0000000000..c00432e0b6 --- /dev/null +++ b/docs/apps-proxy/kai-preview.md @@ -0,0 +1,343 @@ +# Kai-Preview Iframe-Auth: Operator Guide + +## 1. Overview + +apps-proxy exposes a dev-mode-only authentication path that lets the kbc-ui SPA +embed a running data app inside an `