Skip to content

PAT-1788 kai-preview iframe auth on apps-proxy#2601

Draft
pepamartinec wants to merge 32 commits into
mainfrom
pepa/PAT-1788_kaiIframeAuth
Draft

PAT-1788 kai-preview iframe auth on apps-proxy#2601
pepamartinec wants to merge 32 commits into
mainfrom
pepa/PAT-1788_kaiIframeAuth

Conversation

@pepamartinec
Copy link
Copy Markdown
Contributor

@pepamartinec pepamartinec commented May 14, 2026

Release Notes

https://linear.app/keboola/issue/PAT-1788/kai-iframe-auth

Adds a dev-mode-only authentication path for data apps so kbc-ui can embed running apps in an iframe without going through the configured OAuth/Basic prompt. Direct (non-iframe) access to the same app still goes through the configured auth path. Gating is per-app via the existing spec.devMode.enabled field on the App CRD; non-dev-mode apps see the new endpoints as 404.

The flow is four new endpoints under /_proxy/kai-preview/: embed-token (CORS, called by SPA with X-StorageApi-Token — verifies via Storage API, mints a 60s scoped HS256 JWT), bootstrap (serves a postMessage handshake shim with frame-ancestors CSP), exchange (verifies the JWT, sets a host-only SameSite=None; Partitioned; HttpOnly session cookie), and refresh (CORS heartbeat that slides the cookie's Max-Age forward past the midpoint). All validation is stateless across replicas — HMAC-signed JWTs only, no shared store. The session cookie carries no identity claims; apps that need user identity should fall back to KBC_TOKEN (same contract as Basic-auth-protected apps today).

Key changes:

  • New internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/ package (~14 files, 65+ unit tests).
  • apphandler.go adds three new routing branches gated on AppInfo.DevMode: kai-preview path → composite handler; valid session cookie → upstream with sliding refresh; iframe document load → bootstrap shim fallback.
  • New required config keys: kaiPreview.handshakeSigningKey, kaiPreview.sessionSigningKey, kaiPreview.allowedIdeOrigins, storageApiUrl. Generate signing keys with openssl rand -hex 32.

Cross-repo follow-ups:

  • kbc-stacks Helm values need updating per stack — see docs/apps-proxy/kai-preview.md Section 4 for the required keys.
  • kbc-ui SPA-side mint + postMessage integration is a separate PR (Plan B; not yet started).

Plans for customer communication

None.

Impact analysis

No end-user impact — the new endpoints are 404 on apps where spec.devMode.enabled is false (the default), and existing direct-access auth paths are unchanged. Operators must provision the new required signing keys and storageApiUrl before deploying; otherwise the proxy panics at startup.

Change type

New feature

Justification

PAT-1788 — enable embedding dev-mode data apps inside kbc-ui without breaking the existing auth model.

Deployment

Merge & automatic deploy.

Rollback plan

Revert of this PR.

Post release support plan

None.

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.
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.
- 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.
…eAPIURL 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.
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).
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).
… dev-mode gate

Strip trailing slashes from AllowedIDEOrigins in KaiPreview.Normalize() to
prevent silent CORS failures when operators configure origins with a trailing
slash (browsers send Origin headers without one). Add an internal IsDevMode
guard to BootstrapHandler matching the pattern of the other three handlers.
Copilot AI review requested due to automatic review settings May 14, 2026 17:38
@linear
Copy link
Copy Markdown

linear Bot commented May 14, 2026

PAT-1788

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a new dev-mode-only “kai-preview” iframe authentication flow to apps-proxy so kbc-ui can embed running data apps without triggering the app’s configured OAuth/Basic auth prompt, while keeping direct navigation unchanged.

Changes:

  • Introduces a new /_proxy/kai-preview/* endpoint suite (embed-token/bootstrap/exchange/refresh) with stateless JWT handshake + partitioned session cookie.
  • Extends app routing to (a) route kai-preview endpoints, (b) accept a valid kai-preview session cookie and bypass AuthRules, and (c) serve a bootstrap shim for iframe document loads without a session.
  • Adds configuration keys, docs, and extensive unit/integration tests (including sliding refresh behavior via an injected fake clock).

Reviewed changes

Copilot reviewed 31 out of 31 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
internal/pkg/service/appsproxy/proxy/proxy_test.go Adds router/integration regression tests for kai-preview bootstrap and sliding-refresh behavior.
internal/pkg/service/appsproxy/proxy/apphandler/manager.go Injects a clock into apphandler manager and wires in STA token verification support.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/template/bootstrap.gohtml Adds the bootstrap HTML shim that performs the postMessage handshake and token exchange.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/sta_verifier.go Implements Storage API token verification used by the embed-token endpoint.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/sta_verifier_test.go Unit tests for STA verification behavior and error handling.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/refresh.go Implements the CORS “heartbeat” refresh endpoint that re-mints the session cookie.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/refresh_test.go Unit tests for refresh endpoint (CORS, dev-mode gating, cookie validation).
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt.go Implements handshake/session JWT minting and verification logic.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/jwt_test.go Unit tests for JWT round-trips, expiry, and refresh midpoint detection.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/iframe_detect.go Adds heuristic detection of iframe document loads (Sec-Fetch-Dest + Accept).
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/iframe_detect_test.go Unit tests for iframe document load detection.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/handler.go Adds composite per-app handler routing kai-preview subpaths to dedicated handlers.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/handler_test.go Unit tests verifying composite handler routes requests to the right sub-handler.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/exchange.go Implements handshake-token exchange for a host-only partitioned session cookie.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/exchange_test.go Unit tests for token exchange behavior and dev-mode gating.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token.go Implements CORS embed-token minting via STA verification + handshake JWT issuance.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/embed_token_test.go Unit tests for embed-token endpoint (CORS, STA errors, dev-mode gating).
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/csp.go Adds frame-ancestors CSP helper for the bootstrap shim responses.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/csp_test.go Unit tests for CSP header generation.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cors.go Adds a small CORS helper for embed-token/refresh endpoints.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cors_test.go Unit tests for CORS preflight and response header behavior.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie.go Adds session cookie helpers (set/clear/read/validate) for kai-preview sessions.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/cookie_test.go Unit tests for cookie attributes and validation behavior.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/bootstrap.go Implements bootstrap handler serving the HTML shim + CSP.
internal/pkg/service/appsproxy/proxy/apphandler/authproxy/kaipreview/bootstrap_test.go Unit tests for bootstrap shim HTML content and CSP behavior.
internal/pkg/service/appsproxy/proxy/apphandler/apphandler.go Adds dev-mode kai-preview routing, session-cookie bypass, and iframe bootstrap fallback.
internal/pkg/service/appsproxy/dependencies/mocked.go Extends mocked scope defaults to include kai-preview config and storageApiUrl.
internal/pkg/service/appsproxy/config/config.go Introduces kaiPreview config block + storageApiUrl and normalization for allowed origins.
internal/pkg/service/appsproxy/config/config_test.go Adds tests for kai-preview defaults, required keys, and normalization.
go.mod Promotes github.com/golang-jwt/jwt/v5 to a direct dependency.
docs/apps-proxy/kai-preview.md Adds operator documentation/runbook for configuring and verifying kai-preview.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +170 to +176
// 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)
}
Comment on lines +2464 to +2476
// 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)
Comment on lines +28 to +33
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)
Comment on lines +42 to +49
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")
}
Comment on lines +32 to +45
// 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,
})
Comment on lines 98 to +105
func (c *Config) Normalize() {
}

func (c *KaiPreview) Normalize() {
for i, o := range c.AllowedIDEOrigins {
c.AllowedIDEOrigins[i] = strings.TrimRight(o, "/")
}
}
Comment on lines +24 to +31
func NewBootstrapHandler(allowedIDEOrigins []string, devMode DevModeChecker, appID string) *BootstrapHandler {
bs, _ := json.Marshal(allowedIDEOrigins) // []string round-trip never errors for []string
return &BootstrapHandler{
allowedIDEOrigins: allowedIDEOrigins,
originsJSON: template.JS(bs),
devMode: devMode,
appID: appID,
}
Comment on lines +34 to +39
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
| `/_proxy/kai-preview/embed-token` | `POST` | `X-StorageApi-Token` header (CORS) | Mint a 60 s handshake JWT after verifying the STA token against Storage API |
| `/_proxy/kai-preview/bootstrap` | `GET` | none | Return the postMessage handshake shim HTML; sets `Content-Security-Policy: frame-ancestors <allowed-origins>` |
| `/_proxy/kai-preview/exchange` | `POST` | JWT in JSON body `{"token":"..."}` | Verify handshake JWT, set the `kbc-kai-preview-session` session cookie |
| `/_proxy/kai-preview/refresh` | `POST` | session cookie (CORS) | Re-mint and slide the session cookie; returns `204 No Content` |
Comment on lines +53 to +60
| Key | Default | Notes |
|---|---|---|
| `kaiPreview.handshakeSigningKey` | *(required)* | HMAC-SHA256 key for the 60 s handshake JWT |
| `kaiPreview.sessionSigningKey` | *(required)* | HMAC-SHA256 key for the session cookie JWT |
| `kaiPreview.sessionTTL` | `4h` | Sliding session cookie lifetime |
| `kaiPreview.allowedIdeOrigins` | *(required)* | Origins permitted to call `embed-token` and `refresh`, e.g. `https://connection.keboola.com` |
| `storageApiUrl` | `https://connection.keboola.com` | Storage API base URL used to verify STA tokens in `embed-token` |

Unifies endpoint, handler, and constant naming with the existing
HandshakeClaims/MintHandshakeJWT/purposeHandshake symbols. The purpose
claim value changes from "kai-preview-embed" to "kai-preview-handshake".
Replace the opaque "STA" prefix throughout the kai-preview feature with
the clearer "StorageToken" naming. The interface carries the canonical
name (StorageTokenVerifier), the HTTP-backed impl gets a kind-specific
name (HTTPStorageTokenVerifier), and all fields, locals, tests, error
strings, and docs are updated to match.

Files renamed via git mv:
  sta_verifier.go       → storage_token_verifier.go
  sta_verifier_test.go  → storage_token_verifier_test.go
@pepamartinec pepamartinec force-pushed the pepa/PAT-1788_kaiIframeAuth branch from 701444c to 9f4f8eb Compare May 15, 2026 05:17
…d helpers

Move the four endpoint handlers (handshake-token, bootstrap, exchange,
refresh), the composite Handler, and routing symbols (PathPrefix,
DevModeChecker, DevModeCheckerFunc) into a new
internal/…/kaipreview/endpoints sub-package (package name "endpoints").
The parent kaipreview package retains the pure helpers: JWT minting/
verification, cookie helpers, CORS, IsIframeDocumentLoad, and the
StorageTokenVerifier interface (moved to storage_token_verifier.go).

External callers (apphandler.go) import both packages: kaipreview for
helpers and kpendpoints alias for the handler/routing surface.
Fix all 52 golangci-lint failures reported by CI run 25903961499:

- noctx(25): replace httptest.NewRequest → NewRequestWithContext(t.Context(), …) in all kaipreview test files
- tagliatelle(3): rename JSON tags app_id → appId and ttl_s → ttlS in jwt.go (HandshakeClaims, SessionClaims)
- errchkjson(7): check errors from json.Marshal / json.NewEncoder().Encode() in bootstrap.go, handshake_token.go, exchange_test.go, storage_token_verifier_test.go
- nilerr(4): add //nolint:nilerr with explanation where HTTP handlers deliberately swallow errors
- gochecknoglobals(2): var PathPrefix → const PathPrefix in handler.go; //nolint:gochecknoglobals on bootstrapTmpl
- gci(4): fix struct-field alignment in manager.go / apphandler.go; add missing spaces after commas in cookie_test.go, cors_test.go
- contextcheck(2): add //nolint:contextcheck on the two false-positive req.Context() call sites in apphandler.go; extract serveKaiPreview helper to reduce nestif complexity
- paralleltest(1)+tparallel(1): add t.Parallel() to TestIsIframeDocumentLoad subtests
- testifylint(1): assert.False(strings.Contains(…)) → assert.NotContains(…)
- errname(1): rename stubErr → stubError in handshake_token_test.go
- gosec G203(1): //nolint:gosec on template.JS(bs) conversion in bootstrap.go
- unparam(1): remove unused devMode bool param from newTestCompositeHandler; inline true
- usetesting(6): context.Background() → t.Context() in storage_token_verifier_test.go
Replace GetFreePort()+Listen() with a direct Listen("127.0.0.1:0") so
the OS assigns a free port atomically, removing the TOCTOU window where
a parallel test could steal the port between the two calls.
@pepamartinec pepamartinec force-pushed the pepa/PAT-1788_kaiIframeAuth branch from d5cf011 to 133daa0 Compare May 15, 2026 08:20
Comment on lines +50 to +54
data := struct {
AllowedIDEOriginsJSON template.JS
}{
AllowedIDEOriginsJSON: h.originsJSON,
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is not doing deep copy or is it ? I think this is shallow which means it's same as using h.originsJSON

Comment on lines +30 to +38
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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Try to embed it into single assert.Equal(t, expectedCookie, c)

Comment on lines +27 to +29
// IsDevMode implements DevModeChecker.
func (f DevModeCheckerFunc) IsDevMode(ctx context.Context, appID string) bool { return f(ctx, appID) }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why it has to be exported?

}

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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why dont we use SDK for verify? 🤔

Comment on lines +35 to +40
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,http_url"`
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Should be added to docker compose

}

func (v *HTTPStorageTokenVerifier) Verify(ctx context.Context, token string) (*StorageTokenVerifyResult, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, v.baseURL+"/v2/storage/tokens/verify", nil)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why don't we use SDK ?

panic("appsproxy: StorageAPIURL is required for kai-preview Storage token verification")
}
storageAPIURL := cfg.StorageAPIURL.String()
storageTokenHTTPClient := &http.Client{Timeout: 5 * time.Second}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Consider using SDK client it's predefined

@pepamartinec
Copy link
Copy Markdown
Contributor Author

@Matovidlo sorry, tohle jeste nemelo jit na ostry review, zatim PoC. Vracim do draftu a pozdeji poslu, az to bude ready.

@pepamartinec pepamartinec marked this pull request as draft May 15, 2026 11:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants