diff --git a/kubernetes/helm/platform-api-helm-chart/values.yaml b/kubernetes/helm/platform-api-helm-chart/values.yaml index e19ee536c5..d3704672a4 100644 --- a/kubernetes/helm/platform-api-helm-chart/values.yaml +++ b/kubernetes/helm/platform-api-helm-chart/values.yaml @@ -136,8 +136,8 @@ config: # Roles the mapping file defines, each a name and the scopes it grants. # Only ap_admin is shipped here — the file-mode admin below names it. # platform-api/resources/role-to-scope-mapping.yaml is the full sample set (ap_admin, - # ap_operator, ap_publisher, ap_subscriber, ap_viewer); copy the entries you - # need from it. An ap: scope the Platform API's OpenAPI spec does not declare + # ap_operator, ap_publisher, ap_developer, ap_subscriber, ap_viewer); copy the + # entries you need from it. An ap: scope the Platform API's OpenAPI spec does not declare # fails startup; dp: scopes (API Portal) are checked for shape only. # A resource-level :manage already covers that resource's subresources # (each subresource operation lists the parent :manage in its own accepted diff --git a/platform-api/README.md b/platform-api/README.md index c4aa27a8bf..bc76418b4d 100644 --- a/platform-api/README.md +++ b/platform-api/README.md @@ -348,7 +348,7 @@ key silently ignored. #### Role-Based Access Control (RBAC) Per-route scope checks are enforced when `platform_api.auth.authorization.enabled = true`. The -shipped [`resources/role-to-scope-mapping.yaml`](resources/role-to-scope-mapping.yaml) defines five roles, each granting scopes in +shipped [`resources/role-to-scope-mapping.yaml`](resources/role-to-scope-mapping.yaml) defines six roles, each granting scopes in both the `ap:*` (Platform API) and `dp:*` (Developer Portal) namespaces — one role covers a persona across both components: @@ -357,6 +357,7 @@ across both components: | `ap_admin` | Platform administrator | Every resource and operation, both components | | `ap_operator` | Platform operator / CI-CD service account | Gateways, deployments, subscription plans, key managers, webhooks; reads everything else | | `ap_publisher` | API publisher | Full API/MCP/LLM lifecycle and its Developer Portal content; reads applications, subscriptions, plans | +| `ap_developer` | API developer | Creates, updates and deploys APIs/proxies in an existing project and calls them through its own application and keys; deletes the APIs and proxies it created, but not projects or secrets, and publishes no portal content | | `ap_subscriber` | API consumer | Own applications, subscriptions and keys; reads the API/MCP catalog and plans | | `ap_viewer` | Auditor | Read-only across both components | @@ -410,7 +411,7 @@ roles = ["ap_admin"] # expanded via auth.authorization.rol ``` `roles` is a list, so a user whose persona spans two shipped roles names both rather than needing a -sixth role defined for the combination — `roles = ["ap_publisher", "ap_subscriber"]` grants the union +seventh role defined for the combination — `roles = ["ap_publisher", "ap_subscriber"]` grants the union of the two, most-permissive wins, with duplicate scopes collapsed. The issued token carries **both**: the expanded scopes as the `scope` claim, and the role names as the diff --git a/platform-api/resources/role-to-scope-mapping.yaml b/platform-api/resources/role-to-scope-mapping.yaml index 5861ef6d7e..e1c011a2d7 100644 --- a/platform-api/resources/role-to-scope-mapping.yaml +++ b/platform-api/resources/role-to-scope-mapping.yaml @@ -104,6 +104,44 @@ roles: - dp:webhook_subscriber:manage - dp:event:read + # API developer — builds APIs, MCP proxies and LLM proxies in an existing + # project and calls them through its own application; owns the APIs and + # proxies it creates but cannot delete projects or secrets. + - name: ap_developer + scopes: + # Platform API + - ap:organization:read + - ap:project:read + - ap:rest_api:manage + - ap:mcp_proxy:manage + - ap:llm_proxy:manage + - ap:llm_provider:read + - ap:llm_template:read + - ap:gateway:read + - ap:gateway:manifest:read + - ap:gateway_custom_policy:read + - ap:secret:create + - ap:secret:read + - ap:secret:update + - ap:application:manage + - ap:subscription:manage + - ap:subscription_plan:read + - ap:api_key:read + - dp:application:manage + - dp:application_key:manage + - dp:application_key:revoke + - dp:subscription:manage + - dp:api_key:manage + - dp:mcp_server_key:manage + - dp:organization:read + - dp:organization_content:read + - dp:api:read + - dp:mcp_server:read + - dp:mcp_server_content:read + - dp:subscription_plan:read + - dp:view:read + - dp:label:read + - dp:event:read # Platform operator / CI-CD service account — runs gateways, deployments, # subscription plans, key managers and webhooks; reads everything else. - name: ap_operator diff --git a/portals/ai-workspace/bff/internal/auth/oidc.go b/portals/ai-workspace/bff/internal/auth/oidc.go index b3a33b9ff9..3e3c937a9a 100644 --- a/portals/ai-workspace/bff/internal/auth/oidc.go +++ b/portals/ai-workspace/bff/internal/auth/oidc.go @@ -123,6 +123,11 @@ func (o *OIDC) Close() { o.closeOnce.Do(func() { close(o.done) }) } +// TokenEndpoint is the endpoint discovered from the issuer. Exposed so a token +// exchange configured without an explicit endpoint override can post to the same +// IDP the user logged in to, without repeating discovery. +func (o *OIDC) TokenEndpoint() string { return o.disco.TokenEndpoint } + func fetchDiscovery(ctx context.Context, client *http.Client, issuer string) (discoveryDoc, error) { u := strings.TrimRight(issuer, "/") + "/.well-known/openid-configuration" req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) diff --git a/portals/ai-workspace/bff/internal/auth/tokenexchange.go b/portals/ai-workspace/bff/internal/auth/tokenexchange.go new file mode 100644 index 0000000000..8dd1256f17 --- /dev/null +++ b/portals/ai-workspace/bff/internal/auth/tokenexchange.go @@ -0,0 +1,307 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "slices" + "strings" + "time" + + "ai-workspace-bff/internal/config" + "ai-workspace-bff/internal/session" +) + +// RFC 8693 §3 token type identifiers. +const ( + TokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token" + TokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt" + TokenTypeIDToken = "urn:ietf:params:oauth:token-type:id_token" + TokenTypeRefresh = "urn:ietf:params:oauth:token-type:refresh_token" +) + +const ( + grantURITokenExchange = "urn:ietf:params:oauth:grant-type:token-exchange" + grantURIJWTBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer" +) + +const ( + exchangeTimeout = 15 * time.Second + maxExchangeResponseBytes = 1 << 20 +) + +// ErrExchangeUnavailable means the IDP could not be reached or failed server-side; +// retrying may succeed, so callers keep the session and report 502. +var ErrExchangeUnavailable = errors.New("token exchange upstream unavailable") + +// ErrExchangeRejected means the IDP refused the exchange. The session can never +// produce an upstream token, so callers destroy it and report 401. +var ErrExchangeRejected = errors.New("token exchange rejected by identity provider") + +// Exchanger trades the login token for one minted for the Platform API. Its +// settings come from [ai_workspace.auth.oidc.token_exchange]. +// +// No method returns the unexchanged subject token: a failed exchange is an error, +// never a fallback. Forwarding the login token instead would reach the Platform API +// with the wrong audience and, on an IDP that mints no ap:* scopes, no platform +// authorization at all. +type Exchanger struct { + client *http.Client + cfg config.TokenExchangeConfig + endpoint string + requestedScopes []string +} + +// NewExchanger builds an Exchanger for an already-validated config. endpoint is the +// resolved token endpoint, so discovery stays with the OIDC client. +func NewExchanger(client *http.Client, cfg config.TokenExchangeConfig, endpoint string) *Exchanger { + return &Exchanger{ + client: client, + cfg: cfg, + endpoint: endpoint, + requestedScopes: strings.Fields(cfg.Scopes), + } +} + +func (e *Exchanger) Endpoint() string { return e.endpoint } +func (e *Exchanger) CacheEnabled() bool { return e.cfg.CacheEnabled } +func (e *Exchanger) MinValidity() time.Duration { return e.cfg.MinValidity } + +// ConfigFingerprint identifies the settings that determine what the IDP mints, so a +// cached token is not reused after they change. Credentials are excluded: they +// authenticate the BFF without altering the issued token. +func (e *Exchanger) ConfigFingerprint() string { + return strings.Join([]string{e.cfg.GrantType, e.cfg.Audience, e.cfg.Resource, e.cfg.Scopes}, "\x1f") +} + +// Result is one completed exchange. A zero Expiry means the IDP supplied no lifetime, +// which callers must treat as uncacheable rather than as expired. +type Result struct { + AccessToken string + Expiry time.Time + Scopes []string +} + +type exchangeResponse struct { + AccessToken string `json:"access_token"` + IssuedTokenType string `json:"issued_token_type"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Scope string `json:"scope"` + RefreshToken string `json:"refresh_token"` +} + +type exchangeError struct { + Code string `json:"error"` + Description string `json:"error_description"` +} + +// Exchange trades subjectToken for a Platform API token. subjectToken is a live +// credential and is never logged. +func (e *Exchanger) Exchange(ctx context.Context, subjectToken string) (*Result, error) { + if subjectToken == "" { + return nil, fmt.Errorf("%w: no subject token", ErrExchangeRejected) + } + + ctx, cancel := context.WithTimeout(ctx, exchangeTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, e.endpoint, + strings.NewReader(e.buildForm(subjectToken).Encode())) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrExchangeUnavailable, err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + res, err := e.client.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrExchangeUnavailable, err) + } + defer res.Body.Close() + + body, err := io.ReadAll(io.LimitReader(res.Body, maxExchangeResponseBytes)) + if err != nil { + return nil, fmt.Errorf("%w: reading response: %v", ErrExchangeUnavailable, err) + } + if res.StatusCode != http.StatusOK { + return nil, e.classifyError(res.StatusCode, body) + } + + var tok exchangeResponse + if err := json.Unmarshal(body, &tok); err != nil { + return nil, fmt.Errorf("%w: decoding response: %v", ErrExchangeUnavailable, err) + } + if tok.AccessToken == "" { + return nil, fmt.Errorf("%w: response carried no access_token", ErrExchangeUnavailable) + } + // issued_token_type is an RFC 8693 field; Entra's OBO response has none. + if e.cfg.GrantType == config.GrantTokenExchange { + if err := validateIssuedTokenType(tok.IssuedTokenType); err != nil { + return nil, fmt.Errorf("%w: %v", ErrExchangeRejected, err) + } + } + + claims := session.DecodeJWTClaims(tok.AccessToken) + return &Result{ + AccessToken: tok.AccessToken, + Expiry: expiryFrom(tok.ExpiresIn, claims), + Scopes: e.grantedScopes(tok.Scope, claims), + }, nil +} + +func (e *Exchanger) buildForm(subjectToken string) url.Values { + form := url.Values{ + "client_id": {e.cfg.ClientID}, + "client_secret": {e.cfg.ClientSecret}, + } + + // Entra ID does not implement RFC 8693 — it rejects that grant. Its on-behalf-of + // flow is RFC 7523: the subject travels as `assertion`, and the target API is + // named through `scope`, so audience/resource have no place in the request. + if e.cfg.GrantType == config.GrantJWTBearer { + form.Set("grant_type", grantURIJWTBearer) + form.Set("assertion", subjectToken) + form.Set("requested_token_use", "on_behalf_of") + if e.cfg.Scopes != "" { + form.Set("scope", e.cfg.Scopes) + } + return form + } + + form.Set("grant_type", grantURITokenExchange) + form.Set("subject_token", subjectToken) + form.Set("subject_token_type", e.cfg.SubjectTokenType) + form.Set("requested_token_type", e.cfg.RequestedTokenType) + // config.validate permits at most one: IDPs read them differently (PingFederate + // treats them as distinct selectors), so sending both leaves the target ambiguous. + if e.cfg.Audience != "" { + form.Set("audience", e.cfg.Audience) + } + if e.cfg.Resource != "" { + form.Set("resource", e.cfg.Resource) + } + if e.cfg.Scopes != "" { + form.Set("scope", e.cfg.Scopes) + } + return form +} + +// classifyError logs the IDP's reason and returns a sentinel. The reason stays +// internal: whether the subject or the target was refused maps out the deployment's +// trust configuration. +func (e *Exchanger) classifyError(status int, body []byte) error { + var ee exchangeError + _ = json.Unmarshal(body, &ee) + + attrs := []any{"status", status, "grant_type", e.cfg.GrantType, "endpoint", e.endpoint} + if ee.Code != "" { + attrs = append(attrs, "idp_error", ee.Code) + } + if ee.Description != "" { + attrs = append(attrs, "idp_error_description", ee.Description) + } + + if status >= http.StatusInternalServerError { + slog.Error("token exchange failed: identity provider error", attrs...) + return fmt.Errorf("%w: status %d", ErrExchangeUnavailable, status) + } + // RFC 8693 §2.2.2. An operator must fix this, so it is not a per-user warning. + if ee.Code == "invalid_target" { + slog.Error("token exchange rejected: audience/resource not accepted by the IDP — "+ + "register it on the exchanging application, or correct "+ + "[auth.oidc.token_exchange] audience / resource", + append(attrs, "configured_audience", e.cfg.Audience, "configured_resource", e.cfg.Resource)...) + return fmt.Errorf("%w: invalid_target", ErrExchangeRejected) + } + slog.Warn("token exchange rejected", attrs...) + return fmt.Errorf("%w: %s", ErrExchangeRejected, ee.Code) +} + +// grantedScopes prefers the response's scope, which RFC 8693 §2.2.1 makes REQUIRED +// whenever it differs from the request, and falls back to the issued token's claim. +// +// The widening check cannot be an enforcement point — only the IDP knows the subject +// token's grant — but scopes the BFF never asked for mean the exchanging application +// is over-provisioned, which is worth surfacing. +func (e *Exchanger) grantedScopes(granted string, claims map[string]any) []string { + scopes := strings.Fields(granted) + if len(scopes) == 0 { + scopes = scopeClaim(claims) + } + if len(e.requestedScopes) == 0 { + return scopes + } + var extra []string + for _, s := range scopes { + if !slices.Contains(e.requestedScopes, s) { + extra = append(extra, s) + } + } + if len(extra) > 0 { + slog.Warn("token exchange returned scopes that were not requested — "+ + "the exchanging application may be over-provisioned at the IDP", + "unrequested_scopes", extra) + } + return scopes +} + +func validateIssuedTokenType(t string) error { + switch t { + case TokenTypeAccessToken, TokenTypeJWT: + return nil + case "": + return errors.New("response omitted the required issued_token_type") + default: + return fmt.Errorf("issued_token_type %q is not usable as an upstream bearer token", t) + } +} + +// expiryFrom falls back to the exp claim because RFC 8693 only RECOMMENDS expires_in. +func expiryFrom(expiresIn int64, claims map[string]any) time.Time { + if expiresIn > 0 { + return time.Now().Add(time.Duration(expiresIn) * time.Second) + } + return session.ExpiryFromClaims(claims) +} + +func scopeClaim(claims map[string]any) []string { + raw, ok := claims["scope"] + if !ok { + raw = claims["scp"] + } + switch v := raw.(type) { + case string: + return strings.Fields(v) + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + return out + } + return nil +} diff --git a/portals/ai-workspace/bff/internal/auth/tokenexchange_test.go b/portals/ai-workspace/bff/internal/auth/tokenexchange_test.go new file mode 100644 index 0000000000..c53513cf3d --- /dev/null +++ b/portals/ai-workspace/bff/internal/auth/tokenexchange_test.go @@ -0,0 +1,446 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package auth + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "ai-workspace-bff/internal/config" +) + +// jwtWithClaims builds an unsigned JWT with the given claims. The BFF never verifies +// signatures (the Platform API does, via JWKS), so an unsigned token is sufficient to +// exercise the claim-decoding paths. +func jwtWithClaims(t *testing.T, claims map[string]any) string { + t.Helper() + enc := func(v any) string { + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return base64.RawURLEncoding.EncodeToString(b) + } + return enc(map[string]any{"alg": "none", "typ": "JWT"}) + "." + enc(claims) + ".sig" +} + +// exchangeServer stands in for the IDP token endpoint, capturing the received form so +// tests can assert the exact wire format each grant produces. +type exchangeServer struct { + *httptest.Server + lastForm url.Values + calls int +} + +func newExchangeServer(t *testing.T, status int, body any) *exchangeServer { + t.Helper() + es := &exchangeServer{} + es.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Errorf("parse form: %v", err) + } + es.lastForm = r.PostForm + es.calls++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if body != nil { + _ = json.NewEncoder(w).Encode(body) + } + })) + t.Cleanup(es.Close) + return es +} + +func baseCfg() config.TokenExchangeConfig { + return config.TokenExchangeConfig{ + Enabled: true, + GrantType: config.GrantTokenExchange, + ClientID: "bff-client", + ClientSecret: "bff-secret", + Audience: "platform-api", + Scopes: "ap:project:read ap:gateway:read", + SubjectTokenType: TokenTypeJWT, + RequestedTokenType: TokenTypeAccessToken, + CacheEnabled: true, + MinValidity: 60 * time.Second, + } +} + +// TestExchangeSendsRFC8693Form pins the RFC 8693 request shape. The parameter names +// are a wire contract with every IDP in the support matrix, so a rename here is a +// breaking change and must fail a test rather than surface as an IDP rejection. +func TestExchangeSendsRFC8693Form(t *testing.T) { + srv := newExchangeServer(t, http.StatusOK, map[string]any{ + "access_token": "issued-token", + "issued_token_type": TokenTypeAccessToken, + "token_type": "Bearer", + "expires_in": 3600, + }) + + e := NewExchanger(srv.Client(), baseCfg(), srv.URL) + if _, err := e.Exchange(context.Background(), "subject-token"); err != nil { + t.Fatalf("Exchange: %v", err) + } + + want := map[string]string{ + "grant_type": grantURITokenExchange, + "subject_token": "subject-token", + "subject_token_type": TokenTypeJWT, + "requested_token_type": TokenTypeAccessToken, + "audience": "platform-api", + "scope": "ap:project:read ap:gateway:read", + "client_id": "bff-client", + "client_secret": "bff-secret", + } + for k, v := range want { + if got := srv.lastForm.Get(k); got != v { + t.Errorf("form[%q] = %q, want %q", k, got, v) + } + } + // resource must be absent when audience is used, or an IDP that reads both has + // two conflicting targets to choose between. + if _, present := srv.lastForm["resource"]; present { + t.Error("resource must not be sent when audience is set") + } + // The jwt-bearer-only parameter must never leak into the RFC 8693 grant. + if _, present := srv.lastForm["assertion"]; present { + t.Error("assertion must not be sent for the token_exchange grant") + } +} + +// TestExchangeSendsJWTBearerForm pins Entra's on-behalf-of shape, which is a different +// specification and not a dialect of RFC 8693 — the subject travels as `assertion`, +// and there is no subject_token/audience at all. +func TestExchangeSendsJWTBearerForm(t *testing.T) { + srv := newExchangeServer(t, http.StatusOK, map[string]any{ + "access_token": "issued-token", + "token_type": "Bearer", + "expires_in": 3600, + }) + + cfg := baseCfg() + cfg.GrantType = config.GrantJWTBearer + cfg.Audience = "" + cfg.Scopes = "api://platform/.default" + + e := NewExchanger(srv.Client(), cfg, srv.URL) + if _, err := e.Exchange(context.Background(), "subject-token"); err != nil { + t.Fatalf("Exchange: %v", err) + } + + if got := srv.lastForm.Get("grant_type"); got != grantURIJWTBearer { + t.Errorf("grant_type = %q, want %q", got, grantURIJWTBearer) + } + if got := srv.lastForm.Get("assertion"); got != "subject-token" { + t.Errorf("assertion = %q, want the subject token", got) + } + if got := srv.lastForm.Get("requested_token_use"); got != "on_behalf_of" { + t.Errorf("requested_token_use = %q, want on_behalf_of", got) + } + if got := srv.lastForm.Get("scope"); got != "api://platform/.default" { + t.Errorf("scope = %q", got) + } + for _, absent := range []string{"subject_token", "subject_token_type", "requested_token_type", "audience", "resource"} { + if _, present := srv.lastForm[absent]; present { + t.Errorf("%s must not be sent for the jwt_bearer grant", absent) + } + } +} + +// TestExchangeSendsResourceWhenConfigured covers the IDPs that name the target with +// `resource` rather than `audience` (PingFederate reads them as distinct selectors). +func TestExchangeSendsResourceWhenConfigured(t *testing.T) { + srv := newExchangeServer(t, http.StatusOK, map[string]any{ + "access_token": "issued-token", + "issued_token_type": TokenTypeAccessToken, + "token_type": "Bearer", + "expires_in": 3600, + }) + + cfg := baseCfg() + cfg.Audience = "" + cfg.Resource = "https://platform-api.example.com" + + e := NewExchanger(srv.Client(), cfg, srv.URL) + if _, err := e.Exchange(context.Background(), "subject-token"); err != nil { + t.Fatalf("Exchange: %v", err) + } + if got := srv.lastForm.Get("resource"); got != "https://platform-api.example.com" { + t.Errorf("resource = %q", got) + } + if _, present := srv.lastForm["audience"]; present { + t.Error("audience must not be sent when resource is set") + } +} + +// TestExchangeExpiryFromExpiresIn and the exp-claim fallback below cover RFC 8693's +// expires_in being only RECOMMENDED: an IDP omitting it must not yield a token the BFF +// treats as instantly expired. +func TestExchangeExpiryFromExpiresIn(t *testing.T) { + srv := newExchangeServer(t, http.StatusOK, map[string]any{ + "access_token": "issued-token", + "issued_token_type": TokenTypeAccessToken, + "token_type": "Bearer", + "expires_in": 3600, + }) + + e := NewExchanger(srv.Client(), baseCfg(), srv.URL) + res, err := e.Exchange(context.Background(), "subject-token") + if err != nil { + t.Fatalf("Exchange: %v", err) + } + if d := time.Until(res.Expiry); d < 59*time.Minute || d > 61*time.Minute { + t.Errorf("expiry ~1h expected, got %s away", d) + } +} + +func TestExchangeExpiryFallsBackToExpClaim(t *testing.T) { + exp := time.Now().Add(30 * time.Minute).Unix() + issued := jwtWithClaims(t, map[string]any{"exp": exp, "scope": "ap:project:read"}) + srv := newExchangeServer(t, http.StatusOK, map[string]any{ + "access_token": issued, + "issued_token_type": TokenTypeAccessToken, + "token_type": "Bearer", + // expires_in deliberately omitted. + }) + + e := NewExchanger(srv.Client(), baseCfg(), srv.URL) + res, err := e.Exchange(context.Background(), "subject-token") + if err != nil { + t.Fatalf("Exchange: %v", err) + } + if res.Expiry.IsZero() { + t.Fatal("expiry must fall back to the issued token's exp claim") + } + if d := time.Until(res.Expiry); d < 29*time.Minute || d > 31*time.Minute { + t.Errorf("expiry ~30m expected, got %s away", d) + } +} + +// TestExchangeScopesPreferResponseOverClaim covers the downscoping case: RFC 8693 +// makes the response's scope REQUIRED when it differs from the request, so an echoed +// value is authoritative over whatever the token's own claim says. +func TestExchangeScopesPreferResponseOverClaim(t *testing.T) { + issued := jwtWithClaims(t, map[string]any{"scope": "ap:gateway:read ap:project:read"}) + srv := newExchangeServer(t, http.StatusOK, map[string]any{ + "access_token": issued, + "issued_token_type": TokenTypeAccessToken, + "token_type": "Bearer", + "expires_in": 600, + "scope": "ap:project:read", + }) + + e := NewExchanger(srv.Client(), baseCfg(), srv.URL) + res, err := e.Exchange(context.Background(), "subject-token") + if err != nil { + t.Fatalf("Exchange: %v", err) + } + if len(res.Scopes) != 1 || res.Scopes[0] != "ap:project:read" { + t.Errorf("granted scopes = %v, want the downscoped response value", res.Scopes) + } +} + +func TestExchangeScopesFallBackToTokenClaim(t *testing.T) { + issued := jwtWithClaims(t, map[string]any{"scope": "ap:project:read ap:gateway:read"}) + srv := newExchangeServer(t, http.StatusOK, map[string]any{ + "access_token": issued, + "issued_token_type": TokenTypeAccessToken, + "token_type": "Bearer", + "expires_in": 600, + // scope omitted — per the RFC that means the grant matched the request. + }) + + e := NewExchanger(srv.Client(), baseCfg(), srv.URL) + res, err := e.Exchange(context.Background(), "subject-token") + if err != nil { + t.Fatalf("Exchange: %v", err) + } + if len(res.Scopes) != 2 { + t.Errorf("granted scopes = %v, want both from the token claim", res.Scopes) + } +} + +// TestExchangeRejectsUnusableIssuedTokenType guards the configuration error where an +// IDP hands back something that cannot serve as an upstream bearer token. Better a +// failed exchange with a named cause than an opaque 401 from the Platform API. +func TestExchangeRejectsUnusableIssuedTokenType(t *testing.T) { + for _, tc := range []struct { + name string + issued string + wantError bool + }{ + {"access_token", TokenTypeAccessToken, false}, + {"jwt", TokenTypeJWT, false}, + {"refresh_token", TokenTypeRefresh, true}, + {"id_token", TokenTypeIDToken, true}, + {"missing", "", true}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newExchangeServer(t, http.StatusOK, map[string]any{ + "access_token": "issued-token", + "issued_token_type": tc.issued, + "token_type": "Bearer", + "expires_in": 600, + }) + e := NewExchanger(srv.Client(), baseCfg(), srv.URL) + _, err := e.Exchange(context.Background(), "subject-token") + if tc.wantError { + if !errors.Is(err, ErrExchangeRejected) { + t.Errorf("err = %v, want ErrExchangeRejected", err) + } + return + } + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +// TestExchangeJWTBearerToleratesMissingIssuedTokenType: issued_token_type is an +// RFC 8693 field. Entra's OBO response has none, so requiring it would break the +// grant this feature added Entra support for. +func TestExchangeJWTBearerToleratesMissingIssuedTokenType(t *testing.T) { + srv := newExchangeServer(t, http.StatusOK, map[string]any{ + "access_token": "issued-token", + "token_type": "Bearer", + "expires_in": 600, + }) + cfg := baseCfg() + cfg.GrantType = config.GrantJWTBearer + cfg.Audience = "" + cfg.Scopes = "api://platform/.default" + + e := NewExchanger(srv.Client(), cfg, srv.URL) + if _, err := e.Exchange(context.Background(), "subject-token"); err != nil { + t.Errorf("jwt_bearer must not require issued_token_type: %v", err) + } +} + +// TestExchangeErrorClassification maps the IDP's reply onto the two sentinels, which +// is what decides whether the user is logged out (rejected) or shown a transient +// failure (unavailable). RFC 8693 §2.2.2 makes invalid_request the code for a bad +// subject token, so it must not be mistaken for a transport fault. +func TestExchangeErrorClassification(t *testing.T) { + for _, tc := range []struct { + name string + status int + body any + wantErr error + }{ + {"invalid_request is a rejection", http.StatusBadRequest, + map[string]string{"error": "invalid_request"}, ErrExchangeRejected}, + {"invalid_target is a rejection", http.StatusBadRequest, + map[string]string{"error": "invalid_target"}, ErrExchangeRejected}, + {"invalid_client is a rejection", http.StatusUnauthorized, + map[string]string{"error": "invalid_client"}, ErrExchangeRejected}, + {"500 is unavailable, not a rejection", http.StatusInternalServerError, + map[string]string{"error": "server_error"}, ErrExchangeUnavailable}, + {"503 is unavailable", http.StatusServiceUnavailable, nil, ErrExchangeUnavailable}, + } { + t.Run(tc.name, func(t *testing.T) { + srv := newExchangeServer(t, tc.status, tc.body) + e := NewExchanger(srv.Client(), baseCfg(), srv.URL) + _, err := e.Exchange(context.Background(), "subject-token") + if !errors.Is(err, tc.wantErr) { + t.Errorf("err = %v, want %v", err, tc.wantErr) + } + }) + } +} + +// TestExchangeRejectsEmptyAccessToken: a 200 with no token is a malformed response, +// not a success. Without this the BFF would forward an empty bearer header upstream. +func TestExchangeRejectsEmptyAccessToken(t *testing.T) { + srv := newExchangeServer(t, http.StatusOK, map[string]any{ + "issued_token_type": TokenTypeAccessToken, + "token_type": "Bearer", + }) + e := NewExchanger(srv.Client(), baseCfg(), srv.URL) + if _, err := e.Exchange(context.Background(), "subject-token"); !errors.Is(err, ErrExchangeUnavailable) { + t.Errorf("err = %v, want ErrExchangeUnavailable", err) + } +} + +// TestExchangeRejectsEmptySubjectToken fails closed without a network call: there is +// nothing to exchange, and posting an empty subject_token would only waste a round +// trip to learn that. +func TestExchangeRejectsEmptySubjectToken(t *testing.T) { + srv := newExchangeServer(t, http.StatusOK, nil) + e := NewExchanger(srv.Client(), baseCfg(), srv.URL) + if _, err := e.Exchange(context.Background(), ""); !errors.Is(err, ErrExchangeRejected) { + t.Errorf("err = %v, want ErrExchangeRejected", err) + } + if srv.calls != 0 { + t.Errorf("token endpoint called %d times for an empty subject token, want 0", srv.calls) + } +} + +// TestConfigFingerprintChangesWithTarget: the fingerprint is what stops a cached token +// minted for one audience being reused after the audience is reconfigured. +func TestConfigFingerprintChangesWithTarget(t *testing.T) { + base := baseCfg() + e1 := NewExchanger(nil, base, "https://idp.example.com/token") + + changed := base + changed.Audience = "other-api" + e2 := NewExchanger(nil, changed, "https://idp.example.com/token") + + if e1.ConfigFingerprint() == e2.ConfigFingerprint() { + t.Error("fingerprint must change when the audience changes") + } + + narrower := base + narrower.Scopes = "ap:project:read" + e3 := NewExchanger(nil, narrower, "https://idp.example.com/token") + if e1.ConfigFingerprint() == e3.ConfigFingerprint() { + t.Error("fingerprint must change when the requested scopes change") + } + + // Credentials authenticate the BFF without altering what the IDP mints, so they + // deliberately do not invalidate a cached token. + sameToken := base + sameToken.ClientSecret = "rotated-secret" + e4 := NewExchanger(nil, sameToken, "https://idp.example.com/token") + if e1.ConfigFingerprint() != e4.ConfigFingerprint() { + t.Error("rotating the client secret must not invalidate cached tokens") + } +} + +// TestExchangeNeverLogsTokens is a guard on GO-AUTH-003: the subject token is a live +// credential and must not reach a log line. It checks the error text, which is the +// value most likely to be logged verbatim by a caller. +func TestExchangeErrorsDoNotContainTokens(t *testing.T) { + const secret = "super-secret-subject-token" + srv := newExchangeServer(t, http.StatusBadRequest, map[string]string{"error": "invalid_request"}) + e := NewExchanger(srv.Client(), baseCfg(), srv.URL) + _, err := e.Exchange(context.Background(), secret) + if err == nil { + t.Fatal("expected an error") + } + if strings.Contains(err.Error(), secret) { + t.Errorf("error text leaks the subject token: %q", err.Error()) + } +} diff --git a/portals/ai-workspace/bff/internal/config/config.go b/portals/ai-workspace/bff/internal/config/config.go index 6d57f5ee6e..f5e0867bd8 100644 --- a/portals/ai-workspace/bff/internal/config/config.go +++ b/portals/ai-workspace/bff/internal/config/config.go @@ -28,6 +28,7 @@ import ( "fmt" "log/slog" "net/url" + "slices" "strings" "time" @@ -192,6 +193,66 @@ const ( AuthzModeRole = "role" ) +// TokenExchangeConfig is [ai_workspace.auth.oidc.token_exchange]: the settings for +// the second token call. It is a child of the login table rather than a table of its +// own because the exchange targets the same issuer and defaults its client +// credentials and scope to the login values above (see normalize) — the common +// single-application deployment sets only enabled and audience. It is a table rather +// than a set of token_exchange_* keys because the exchange is genuinely a separate +// OAuth client: an STS deployment routinely registers a distinct client_id for it. +type TokenExchangeConfig struct { + // Enabled is off by default: the AI Workspace forwards the login token upstream + // exactly as it did before this feature existed. + Enabled bool `koanf:"enabled"` + + // GrantType selects the wire protocol — GrantTokenExchange (RFC 8693) or + // GrantJWTBearer (RFC 7523 on-behalf-of, which is what Entra ID speaks). + GrantType string `koanf:"grant_type"` + + // TokenEndpoint defaults to the endpoint discovered from OIDCConfig.Issuer; set + // it only when the exchange happens at a different STS than login. + TokenEndpoint string `koanf:"token_endpoint"` + + // ClientID and ClientSecret default to the login client's (see normalize). Set + // them when the STS registers the exchange as its own application. + ClientID string `koanf:"client_id"` + ClientSecret string `koanf:"client_secret"` + + // Audience becomes the issued token's aud and must match + // [platform_api.auth.idp] audience. One pre-registered value only: WSO2 answers + // invalid_target otherwise, and supports it only after IS 7.3.0. + Audience string `koanf:"audience"` + + // Resource is the alternative target naming, mutually exclusive with Audience. + Resource string `koanf:"resource"` + + // Scopes is requested on the issued token, except under GrantJWTBearer where it + // names the target API ("api:///.default") and is required. + Scopes string `koanf:"scope"` + + // SubjectTokenType defaults to the JWT type, which WSO2 requires; Okta and + // Keycloak expect the access_token type. Both types are unused by GrantJWTBearer. + SubjectTokenType string `koanf:"subject_token_type"` + RequestedTokenType string `koanf:"requested_token_type"` + + CacheEnabled bool `koanf:"cache_enabled"` + MinValidity time.Duration `koanf:"min_validity"` + + // There is deliberately no refresh-token option: RFC 8693 §2.2.1 advises against + // one when trading temporary credentials, and it would outlive the login session + // it derives from. The BFF re-exchanges from the subject token. +} + +// GrantTokenExchange and GrantJWTBearer are the supported grant_type values. +const ( + GrantTokenExchange = "token_exchange" + GrantJWTBearer = "jwt_bearer" +) + +// SupportedTokenExchangeGrants is the closed set [auth.oidc.token_exchange] +// grant_type is validated against. Adding a protocol means adding it here and to Exchanger.buildForm. +var SupportedTokenExchangeGrants = []string{GrantTokenExchange, GrantJWTBearer} + // OIDCConfig is [ai_workspace.auth.oidc]: the confidential-client settings. The client // secret lives only here on the BFF and is never emitted to the browser. Whether the // client is used at all is not a key here — see AuthConfig.OIDCEnabled, which derives it @@ -203,6 +264,18 @@ type OIDCConfig struct { RedirectURL string `koanf:"redirect_url"` // must equal the IDP-registered redirect, points at /api/auth/callback PostLogoutRedirectURL string `koanf:"post_logout_redirect_url"` Scopes string `koanf:"scope"` // space-separated + + // TokenExchange is [ai_workspace.auth.oidc.token_exchange]: trade the login token + // for one minted for the Platform API, so the credential sent upstream is + // audience- and scope-scoped to that API. + // + // The motivating deployment is an IDP that authenticates users but cannot mint + // this platform's ap:* scopes (Microsoft Entra ID — see AuthorizationConfig.Mode), + // which otherwise forces mode = "role" and a grant table mirrored across two + // services. Exchanging at an STS that can mint them replaces that mirror with a + // token whose own scope claim is authoritative on both sides. Operator + // documentation lives in configs/config-template.toml. + TokenExchange TokenExchangeConfig `koanf:"token_exchange"` } // ClaimMappingConfig is [ai_workspace.auth.claim_mappings]: which claim names the BFF @@ -336,9 +409,35 @@ func (c *Config) normalize() { c.ControlPlane.URL = strings.TrimRight(c.ControlPlane.URL, "/") c.Auth.OIDC.Issuer = strings.TrimRight(c.Auth.OIDC.Issuer, "/") + c.Auth.OIDC.TokenExchange.GrantType = strings.ToLower(c.Auth.OIDC.TokenExchange.GrantType) + + if c.Auth.OIDC.TokenExchange.ClientID == "" { + c.Auth.OIDC.TokenExchange.ClientID = c.Auth.OIDC.ClientID + } + if c.Auth.OIDC.TokenExchange.ClientSecret == "" { + c.Auth.OIDC.TokenExchange.ClientSecret = c.Auth.OIDC.ClientSecret + } + // Not inherited for jwt_bearer, where scope names the target API rather than + // requesting permissions; validate requires an explicit value there. + if c.Auth.OIDC.TokenExchange.Scopes == "" && c.Auth.OIDC.TokenExchange.GrantType != GrantJWTBearer { + c.Auth.OIDC.TokenExchange.Scopes = c.Auth.OIDC.Scopes + } + c.Cookie = CookieConfig{Name: cookieName, Secure: true, SameSite: "lax"} } +// TokenExchangeEnabled derives the switch from both flags, so the feature can never +// be half-on: there is no subject token to exchange outside OIDC mode. +func (a AuthConfig) TokenExchangeEnabled() bool { + return a.OIDCEnabled() && a.OIDC.TokenExchange.Enabled +} + +// ExchangeTokenEndpoint returns the configured override, or "" to tell the caller to +// use the endpoint discovered by the OIDC client. +func (a AuthConfig) ExchangeTokenEndpoint() string { + return a.OIDC.TokenExchange.TokenEndpoint +} + // validate fails startup on any value that would otherwise surface as a confusing // runtime error (a bad port, an empty upstream URL, an incomplete OIDC set) and warns // on security-relevant downgrades. @@ -432,6 +531,10 @@ func (c *Config) validate() error { } } + if err := c.validateTokenExchange(); err != nil { + return err + } + // Basic (file-based) auth is supported for quickstart deployments but is not // recommended for production; point operators at OIDC. if !c.Auth.OIDCEnabled() { @@ -441,3 +544,83 @@ func (c *Config) validate() error { return nil } + +// validateTokenExchange fails startup on a configuration that would break every +// request after login. Aggressive precisely because the feature is fail-closed: a +// misconfiguration takes the UI down, so an operator should see it at boot. +func (c *Config) validateTokenExchange() error { + te := c.Auth.OIDC.TokenExchange + + // Checked even when disabled, so a typo surfaces when written rather than on the + // deploy that enables the feature. + if !slices.Contains(SupportedTokenExchangeGrants, te.GrantType) { + return fmt.Errorf("invalid [auth.oidc.token_exchange] grant_type %q: supported values are %s", + te.GrantType, strings.Join(SupportedTokenExchangeGrants, ", ")) + } + + if !te.Enabled { + return nil + } + + if !c.Auth.OIDCEnabled() { + return fmt.Errorf("[auth.oidc.token_exchange] enabled = true requires [auth] mode = %q, got %q", + AuthModeOIDC, c.Auth.Mode) + } + if te.ClientID == "" || te.ClientSecret == "" { + return fmt.Errorf("[auth.oidc.token_exchange] client_id and client_secret are required " + + "(they default to client_id / client_secret on the parent [auth.oidc] table — set either pair)") + } + + if te.TokenEndpoint != "" { + u, err := url.Parse(te.TokenEndpoint) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Errorf("[auth.oidc.token_exchange] token_endpoint must be an absolute http:// or https:// URL, got %q", + te.TokenEndpoint) + } + if u.Scheme == "http" { + slog.Warn("[auth.oidc.token_exchange] token_endpoint is http:// — the subject token and " + + "client secret will cross the network in the clear; use https://") + } + } + + switch te.GrantType { + case GrantTokenExchange: + if te.Audience != "" && te.Resource != "" { + return fmt.Errorf("[auth.oidc.token_exchange] set at most one of audience / resource, not both") + } + // Not required by the RFC — some IDPs derive the target from the exchanging + // application — but a mismatched aud is the most common way this fails. + if te.Audience == "" && te.Resource == "" { + slog.Warn("[auth.oidc.token_exchange] neither audience nor resource is set — the issued " + + "token's aud claim will be whatever the IDP defaults to. It must match " + + "[platform_api.auth.idp] audience, or the Platform API will reject every request.") + } + if te.SubjectTokenType == "" { + return fmt.Errorf("[auth.oidc.token_exchange] subject_token_type is required for grant_type = %q", + GrantTokenExchange) + } + if te.RequestedTokenType == "" { + return fmt.Errorf("[auth.oidc.token_exchange] requested_token_type is required for grant_type = %q", + GrantTokenExchange) + } + + case GrantJWTBearer: + if te.Audience != "" || te.Resource != "" { + return fmt.Errorf("[auth.oidc.token_exchange] audience / resource are not used by "+ + "grant_type = %q (the target API is named through "+ + "scope, e.g. \"api:///.default\") — remove them", GrantJWTBearer) + } + if te.Scopes == "" { + return fmt.Errorf("[auth.oidc.token_exchange] scope is required for grant_type = %q: "+ + "it is how the target API is named", GrantJWTBearer) + } + } + + // A zero window would renew only after expiry, guaranteeing an in-flight expiry. + if te.CacheEnabled && te.MinValidity <= 0 { + return fmt.Errorf("[auth.oidc.token_exchange] min_validity must be positive when cache_enabled = true, got %s", + te.MinValidity) + } + + return nil +} diff --git a/portals/ai-workspace/bff/internal/config/default_config.go b/portals/ai-workspace/bff/internal/config/default_config.go index 736796f358..88f78bcf53 100644 --- a/portals/ai-workspace/bff/internal/config/default_config.go +++ b/portals/ai-workspace/bff/internal/config/default_config.go @@ -84,8 +84,22 @@ func defaultConfig() *Config { }, Auth: AuthConfig{ Mode: "basic", + // Token exchange is off by default, so a deployment that omits the + // [auth.oidc.token_exchange] table forwards the login token exactly as + // before. The token-type defaults suit WSO2 IS / Asgardeo, which accepts + // JWT-typed subject tokens only and requires requested_token_type; Okta and + // Keycloak deployments override the subject type. Scopes stays empty so + // normalize can inherit the login scopes. OIDC: OIDCConfig{ Scopes: defaultOIDCScopes, + TokenExchange: TokenExchangeConfig{ + Enabled: false, + GrantType: GrantTokenExchange, + SubjectTokenType: "urn:ietf:params:oauth:token-type:jwt", + RequestedTokenType: "urn:ietf:params:oauth:token-type:access_token", + CacheEnabled: true, + MinValidity: 60 * time.Second, + }, }, // Defaults mirror the Platform API's own claim_mappings defaults so the two // agree out of the box; override on both sides together when an IDP uses diff --git a/portals/ai-workspace/bff/internal/config/token_exchange_test.go b/portals/ai-workspace/bff/internal/config/token_exchange_test.go new file mode 100644 index 0000000000..562cc02e13 --- /dev/null +++ b/portals/ai-workspace/bff/internal/config/token_exchange_test.go @@ -0,0 +1,471 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// writeTokenExchangeConfig writes a minimal valid config.toml with the given +// [ai_workspace.auth] body appended, and loads it. +func loadWithAuth(t *testing.T, authBody string) (*Config, error) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + content := ` +[ai_workspace] +domain = "localhost:9643" + +[ai_workspace.control_plane] +url = "https://platform-api:9243" + +[ai_workspace.server.https] +enabled = true +port = 9643 +cert_file = "/tmp/cert.pem" +key_file = "/tmp/key.pem" +` + authBody + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + return Load(path) +} + +// TestTokenExchangeDefaultsOff is the compatibility guarantee: a config that says +// nothing about token exchange must behave exactly as it did before the feature +// existed. +func TestTokenExchangeDefaultsOff(t *testing.T) { + cfg, err := loadWithAuth(t, ` +[ai_workspace.auth] +mode = "basic" +`) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Auth.OIDC.TokenExchange.Enabled { + t.Error("token exchange must default to disabled") + } + if cfg.Auth.TokenExchangeEnabled() { + t.Error("TokenExchangeEnabled must be false by default") + } + if got := cfg.Auth.OIDC.TokenExchange.GrantType; got != GrantTokenExchange { + t.Errorf("default grant_type = %q, want %q", got, GrantTokenExchange) + } + if got := cfg.Auth.OIDC.TokenExchange.MinValidity; got != 60*time.Second { + t.Errorf("default min_validity = %s, want 60s", got) + } + if !cfg.Auth.OIDC.TokenExchange.CacheEnabled { + t.Error("caching must default to on") + } +} + +// TestTokenExchangeInheritsOIDCCredentials keeps the common single-application +// deployment down to two keys (enabled + audience). +func TestTokenExchangeInheritsOIDCCredentials(t *testing.T) { + cfg, err := loadWithAuth(t, ` +[ai_workspace.auth] +mode = "oidc" + +[ai_workspace.auth.oidc] +authority = "https://idp.example.com" +client_id = "login-client" +client_secret = "login-secret" +redirect_url = "https://localhost:9643/ai-workspace/api/auth/callback" +scope = "openid ap:project:read" + +[ai_workspace.auth.oidc.token_exchange] +enabled = true +audience = "platform-api" +`) + if err != nil { + t.Fatalf("Load: %v", err) + } + te := cfg.Auth.OIDC.TokenExchange + if te.ClientID != "login-client" || te.ClientSecret != "login-secret" { + t.Errorf("credentials should inherit from [auth.oidc], got %q/%q", te.ClientID, te.ClientSecret) + } + if te.Scopes != "openid ap:project:read" { + t.Errorf("scope should inherit from [auth.oidc], got %q", te.Scopes) + } + if !cfg.Auth.TokenExchangeEnabled() { + t.Error("TokenExchangeEnabled must be true") + } +} + +// TestTokenExchangeSeparateCredentialsWin covers binding exchange rights to a +// narrower, exchange-only application than the login client. +func TestTokenExchangeSeparateCredentialsWin(t *testing.T) { + cfg, err := loadWithAuth(t, ` +[ai_workspace.auth] +mode = "oidc" + +[ai_workspace.auth.oidc] +authority = "https://idp.example.com" +client_id = "login-client" +client_secret = "login-secret" +redirect_url = "https://localhost:9643/ai-workspace/api/auth/callback" + +[ai_workspace.auth.oidc.token_exchange] +enabled = true +audience = "platform-api" +client_id = "exchange-client" +client_secret = "exchange-secret" +scope = "ap:project:read" +`) + if err != nil { + t.Fatalf("Load: %v", err) + } + te := cfg.Auth.OIDC.TokenExchange + if te.ClientID != "exchange-client" || te.ClientSecret != "exchange-secret" { + t.Errorf("explicit credentials must win, got %q/%q", te.ClientID, te.ClientSecret) + } + if te.Scopes != "ap:project:read" { + t.Errorf("explicit scope must win, got %q", te.Scopes) + } +} + +func TestTokenExchangeValidationErrors(t *testing.T) { + const oidcBase = ` +[ai_workspace.auth] +mode = "oidc" + +[ai_workspace.auth.oidc] +authority = "https://idp.example.com" +client_id = "login-client" +client_secret = "login-secret" +redirect_url = "https://localhost:9643/ai-workspace/api/auth/callback" +` + for _, tc := range []struct { + name string + body string + wantSub string + }{ + { + // A typo must not silently leave the feature off or pick a grant at random, + // and the error must name the supported set. + name: "unknown grant type", + body: ` +[ai_workspace.auth] +mode = "basic" + +[ai_workspace.auth.oidc] +[ai_workspace.auth.oidc.token_exchange] +grant_type = "saml-swap" +`, + wantSub: "supported values are token_exchange, jwt_bearer", + }, + { + name: "empty grant type", + body: ` +[ai_workspace.auth] +mode = "basic" + +[ai_workspace.auth.oidc] +[ai_workspace.auth.oidc.token_exchange] +grant_type = "" +`, + wantSub: "supported values are token_exchange, jwt_bearer", + }, + { + // Lowercasing normalizes case, not separators. + name: "hyphenated grant type", + body: ` +[ai_workspace.auth] +mode = "basic" + +[ai_workspace.auth.oidc] +[ai_workspace.auth.oidc.token_exchange] +grant_type = "token-exchange" +`, + wantSub: "supported values are", + }, + { + // Basic mode has no subject token: the JWT the BFF holds is one the + // Platform API signed for itself. + name: "enabled in basic mode", + body: ` +[ai_workspace.auth] +mode = "basic" + +[ai_workspace.auth.oidc] +[ai_workspace.auth.oidc.token_exchange] +enabled = true +audience = "platform-api" +`, + wantSub: `requires [auth] mode = "oidc"`, + }, + { + name: "audience and resource together", + body: oidcBase + ` +[ai_workspace.auth.oidc.token_exchange] +enabled = true +audience = "platform-api" +resource = "https://platform-api.example.com" +`, + wantSub: "at most one of audience / resource", + }, + { + name: "blank subject_token_type", + body: oidcBase + ` +[ai_workspace.auth.oidc.token_exchange] +enabled = true +audience = "platform-api" +subject_token_type = "" +`, + wantSub: "subject_token_type is required", + }, + { + name: "blank requested_token_type", + body: oidcBase + ` +[ai_workspace.auth.oidc.token_exchange] +enabled = true +audience = "platform-api" +requested_token_type = "" +`, + wantSub: "requested_token_type is required", + }, + { + // Entra names the target through scope; audience would be silently dropped. + name: "jwt_bearer with audience", + body: oidcBase + ` +[ai_workspace.auth.oidc.token_exchange] +enabled = true +grant_type = "jwt_bearer" +audience = "platform-api" +scope = "api://platform/.default" +`, + wantSub: "are not used by grant_type", + }, + { + name: "jwt_bearer without scope", + body: oidcBase + ` +[ai_workspace.auth.oidc.token_exchange] +enabled = true +grant_type = "jwt_bearer" +scope = "" +`, + wantSub: "scope is required", + }, + { + name: "relative token_endpoint", + body: oidcBase + ` +[ai_workspace.auth.oidc.token_exchange] +enabled = true +audience = "platform-api" +token_endpoint = "/oauth2/token" +`, + wantSub: "must be an absolute", + }, + { + // A zero window would re-exchange only after expiry, guaranteeing an + // in-flight expiry on every renewal. + name: "non-positive min_validity with cache on", + body: oidcBase + ` +[ai_workspace.auth.oidc.token_exchange] +enabled = true +audience = "platform-api" +cache_enabled = true +min_validity = "0s" +`, + wantSub: "min_validity must be positive", + }, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := loadWithAuth(t, tc.body) + if err == nil { + t.Fatalf("expected a validation error mentioning %q", tc.wantSub) + } + if !strings.Contains(err.Error(), tc.wantSub) { + t.Errorf("error = %q, want it to mention %q", err.Error(), tc.wantSub) + } + }) + } +} + +// TestTokenExchangeValidConfigs are the two shapes the docs tell operators to use; +// they must load without error. +func TestTokenExchangeValidConfigs(t *testing.T) { + for _, tc := range []struct{ name, body string }{ + { + name: "wso2 rfc8693", + body: ` +[ai_workspace.auth] +mode = "oidc" + +[ai_workspace.auth.oidc] +authority = "https://iam.example.com/oauth2/token" +client_id = "login-client" +client_secret = "login-secret" +redirect_url = "https://localhost:9643/ai-workspace/api/auth/callback" + +[ai_workspace.auth.oidc.token_exchange] +enabled = true +audience = "platform-api" +`, + }, + { + name: "entra jwt bearer", + body: ` +[ai_workspace.auth] +mode = "oidc" + +[ai_workspace.auth.oidc] +authority = "https://login.microsoftonline.com/tenant/v2.0" +client_id = "login-client" +client_secret = "login-secret" +redirect_url = "https://localhost:9643/ai-workspace/api/auth/callback" + +[ai_workspace.auth.oidc.token_exchange] +enabled = true +grant_type = "jwt_bearer" +scope = "api://platform-api/.default" +token_endpoint = "https://login.microsoftonline.com/tenant/oauth2/v2.0/token" +`, + }, + { + name: "grant type is case insensitive", + body: ` +[ai_workspace.auth] +mode = "oidc" + +[ai_workspace.auth.oidc] +authority = "https://idp.example.com" +client_id = "c" +client_secret = "s" +redirect_url = "https://localhost:9643/ai-workspace/api/auth/callback" + +[ai_workspace.auth.oidc.token_exchange] +enabled = true +grant_type = "Token_Exchange" +audience = "platform-api" +`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := loadWithAuth(t, tc.body); err != nil { + t.Errorf("Load: %v", err) + } + }) + } +} + +// TestExchangeTokenEndpointOverride: an empty override tells the server to fall back +// to OIDC discovery rather than being an error. +func TestExchangeTokenEndpointOverride(t *testing.T) { + cfg, err := loadWithAuth(t, ` +[ai_workspace.auth] +mode = "oidc" + +[ai_workspace.auth.oidc] +authority = "https://idp.example.com" +client_id = "c" +client_secret = "s" +redirect_url = "https://localhost:9643/ai-workspace/api/auth/callback" + +[ai_workspace.auth.oidc.token_exchange] +enabled = true +audience = "platform-api" +`) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := cfg.Auth.ExchangeTokenEndpoint(); got != "" { + t.Errorf("ExchangeTokenEndpoint = %q, want empty so the server uses discovery", got) + } +} + +// TestTokenExchangeKeysLiveInOIDCSubTable pins the keys to +// [ai_workspace.auth.oidc.token_exchange]. A key in the wrong table is silently +// ignored by koanf rather than rejected, so a misplaced key would leave the feature +// quietly off — hence a test, not a review note. +func TestTokenExchangeKeysLiveInOIDCSubTable(t *testing.T) { + cfg, err := loadWithAuth(t, ` +[ai_workspace.auth] +mode = "oidc" + +[ai_workspace.auth.oidc] +authority = "https://idp.example.com" +client_id = "c" +client_secret = "s" +redirect_url = "https://localhost:9643/ai-workspace/api/auth/callback" +[ai_workspace.auth.oidc.token_exchange] +enabled = true +grant_type = "token_exchange" +token_endpoint = "https://sts.example.com/oauth2/token" +client_id = "exchange-client" +client_secret = "exchange-secret" +audience = "platform-api" +scope = "ap:project:read" +subject_token_type = "urn:ietf:params:oauth:token-type:access_token" +requested_token_type = "urn:ietf:params:oauth:token-type:jwt" +cache_enabled = false +min_validity = "90s" +`) + if err != nil { + t.Fatalf("Load: %v", err) + } + te := cfg.Auth.OIDC.TokenExchange + for _, tc := range []struct { + key string + got, want any + }{ + {"enabled", te.Enabled, true}, + {"grant_type", te.GrantType, GrantTokenExchange}, + {"token_endpoint", te.TokenEndpoint, "https://sts.example.com/oauth2/token"}, + {"client_id", te.ClientID, "exchange-client"}, + {"client_secret", te.ClientSecret, "exchange-secret"}, + {"audience", te.Audience, "platform-api"}, + {"scope", te.Scopes, "ap:project:read"}, + {"subject_token_type", te.SubjectTokenType, "urn:ietf:params:oauth:token-type:access_token"}, + {"requested_token_type", te.RequestedTokenType, "urn:ietf:params:oauth:token-type:jwt"}, + {"cache_enabled", te.CacheEnabled, false}, + {"min_validity", te.MinValidity, 90 * time.Second}, + } { + if tc.got != tc.want { + t.Errorf("%s did not reach the config: got %v, want %v", tc.key, tc.got, tc.want) + } + } +} + +// TestTokenExchangeKeysIgnoredInOldTable documents that the sibling table the keys +// never lived in is inert: keys placed there do NOT silently enable the feature. +func TestTokenExchangeKeysIgnoredInOldTable(t *testing.T) { + cfg, err := loadWithAuth(t, ` +[ai_workspace.auth] +mode = "oidc" + +[ai_workspace.auth.oidc] +authority = "https://idp.example.com" +client_id = "c" +client_secret = "s" +redirect_url = "https://localhost:9643/ai-workspace/api/auth/callback" + +[ai_workspace.auth.token_exchange] +enabled = true +audience = "platform-api" +`) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Auth.TokenExchangeEnabled() { + t.Error("the sibling [auth.token_exchange] table must not enable the feature") + } +} diff --git a/portals/ai-workspace/bff/internal/server/composite_handlers.go b/portals/ai-workspace/bff/internal/server/composite_handlers.go index c035f9252e..81db4a1677 100644 --- a/portals/ai-workspace/bff/internal/server/composite_handlers.go +++ b/portals/ai-workspace/bff/internal/server/composite_handlers.go @@ -141,6 +141,15 @@ func (s *Server) handleCreateWithSecretCompensation(w http.ResponseWriter, r *ht return } + // Reaches the Platform API directly, not through handleProxy, so it must resolve + // the upstream token the same way. + upstreamJWT, exchErr := s.upstreamToken(r.Context(), jwt) + if exchErr != nil { + slog.Warn("token exchange failed for composite request", "path", resourcePath, "err", exchErr) + s.writeExchangeError(w, r, exchErr) + return + } + const maxBodyBytes = 1 << 20 // 1 MiB — ample for any LLM provider or MCP server payload r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) body, err := io.ReadAll(r.Body) @@ -154,7 +163,7 @@ func (s *Server) handleCreateWithSecretCompensation(w http.ResponseWriter, r *ht if q := r.URL.RawQuery; q != "" { path += "?" + q } - resp, err := s.platformDo(r.Context(), jwt, http.MethodPost, path, r.Header, body) + resp, err := s.platformDo(r.Context(), upstreamJWT, http.MethodPost, path, r.Header, body) if err != nil { slog.Error("bff: platform API call failed", "path", resourcePath, "err", err) writeServerErrorJSON(w, http.StatusBadGateway, "UPSTREAM_REQUEST_FAILED", "upstream request failed", w.Header().Get("X-Request-Id")) @@ -165,7 +174,7 @@ func (s *Server) handleCreateWithSecretCompensation(w http.ResponseWriter, r *ht // On failure, compensate by deleting every secret that was already created. if resp.StatusCode >= 400 { for _, handle := range extractSecretHandles(body) { - s.deleteSecretAsync(jwt, handle, apiBasePath) + s.deleteSecretAsync(upstreamJWT, handle, apiBasePath) } } diff --git a/portals/ai-workspace/bff/internal/server/handlers.go b/portals/ai-workspace/bff/internal/server/handlers.go index 7635021f4d..5bf54e65a3 100644 --- a/portals/ai-workspace/bff/internal/server/handlers.go +++ b/portals/ai-workspace/bff/internal/server/handlers.go @@ -113,6 +113,14 @@ func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusUnauthorized, map[string]any{"authenticated": false}) return } + // Not fatal: the session is genuinely authenticated, and the next proxied request + // surfaces the failure with the right status. + if s.exchanger != nil { + if _, err := s.exchangedToken(r.Context(), jwt); err != nil { + slog.Warn("token exchange failed while hydrating session", "err", err) + } + } + writeJSON(w, http.StatusOK, map[string]any{ "authenticated": true, "user": s.userFromToken(r.Context(), jwt), @@ -172,6 +180,18 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, s.path("/login")+"?error=session_failed", http.StatusFound) return } + // Eager, so a misconfiguration surfaces as a failed login rather than as a 502 on + // the SPA's first API call, and the first /api/session already has scopes. + if s.exchanger != nil { + if _, err := s.exchangedToken(r.Context(), sess.AccessToken); err != nil { + slog.Error("token exchange failed at login", "err", err) + _ = s.store.Delete(r.Context(), sess.AccessToken) + s.clearSessionCookie(w) + http.Redirect(w, r, s.path("/login")+"?error=token_exchange_failed", http.StatusFound) + return + } + } + s.setSessionCookie(w, sess.AccessToken, sess.AbsoluteExpiry) http.Redirect(w, r, s.sanitizeReturn(ret), http.StatusFound) } @@ -209,7 +229,14 @@ func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) { } } - s.proxy.ServeHTTP(w, proxy.WithToken(r, jwt)) + upstream, err := s.upstreamToken(r.Context(), jwt) + if err != nil { + slog.Warn("token exchange failed for proxied request", "err", err, "path", r.URL.Path) + s.writeExchangeError(w, r, err) + return + } + + s.proxy.ServeHTTP(w, proxy.WithToken(r, upstream)) } // --------------------------------------------------------------------------- @@ -253,13 +280,28 @@ func (s *Server) tokenFromCookie(r *http.Request) (string, bool) { func (s *Server) userFromToken(ctx context.Context, jwt string) session.User { if s.oidc != nil { if sess, ok, _ := s.store.Get(ctx, jwt); ok { - return sess.User + return s.withExchangedScopes(sess.User, sess.Exchanged) } return s.oidc.UserFromAccessToken(jwt) } return session.UserFromClaims(session.DecodeJWTClaims(jwt), nil, s.claims) } +// withExchangedScopes reports what the Platform API will authorize, which in exchange +// mode the exchanged token decides. This is what lets an IDP that cannot mint ap:* +// scopes run with [auth.authorization] mode = "scope" instead of mirroring a grant +// table across two services. +// +// An empty set is left alone: a freshly restored session has not exchanged yet, and +// blanking scopes would show nothing as permitted for a fully authorized session. +func (s *Server) withExchangedScopes(u session.User, ex session.ExchangedToken) session.User { + if s.exchanger == nil || len(ex.Scopes) == 0 { + return u + } + u.Scopes = ex.Scopes + return u +} + // putRefreshState stores the OIDC refresh/id tokens keyed by the access JWT so // the proxy can renew the token later. The cookie itself carries the JWT. func (s *Server) putRefreshState(ctx context.Context, sess *session.Session) error { @@ -333,6 +375,8 @@ func (s *Server) doRefresh(ctx context.Context, jwt string) (*session.Session, e } updated := s.oidc.SessionFromToken(tok, cur) updated.ID = updated.AccessToken + // SessionFromToken returns a fresh record, so Exchanged is already zero. Do not + // copy cur.Exchanged forward: it was derived from the token that just rotated. // Preserve the original absolute deadline: the hard cap must bound total // session lifetime, not slide forward on every refresh (which would let an // active session live indefinitely and disagree with the cookie's MaxAge). @@ -391,3 +435,121 @@ func (s *Server) sanitizeReturn(p string) string { } return p } + +// --------------------------------------------------------------------------- +// Token exchange +// --------------------------------------------------------------------------- + +// upstreamToken resolves the token to forward to the Platform API. With an exchange +// configured there is no fallback to the subject token: forwarding it would carry the +// wrong audience and, on an IDP that mints no ap:* scopes, no authorization at all. +func (s *Server) upstreamToken(ctx context.Context, subjectToken string) (string, error) { + if s.exchanger == nil { + return subjectToken, nil + } + res, err := s.exchangedToken(ctx, subjectToken) + if err != nil { + return "", err + } + return res.AccessToken, nil +} + +// exchangedToken returns a cached exchanged token when one is still usable, and +// performs an exchange otherwise. +func (s *Server) exchangedToken(ctx context.Context, subjectToken string) (*auth.Result, error) { + fingerprint := s.exchanger.ConfigFingerprint() + + if s.exchanger.CacheEnabled() { + if sess, ok, _ := s.store.Get(ctx, subjectToken); ok { + if sess.Exchanged.Usable(time.Now(), s.exchanger.MinValidity(), fingerprint) { + return &auth.Result{ + AccessToken: sess.Exchanged.Token, + Expiry: sess.Exchanged.Expiry, + Scopes: sess.Exchanged.Scopes, + }, nil + } + } + } + return s.exchangeSingleFlight(ctx, subjectToken, fingerprint) +} + +// exchangeSingleFlight performs one exchange per subject token at a time, mirroring +// refreshByToken's structure. +func (s *Server) exchangeSingleFlight(ctx context.Context, subjectToken, fingerprint string) (*auth.Result, error) { + s.exchangeMu.Lock() + mu := s.exchangeLocks[subjectToken] + if mu == nil { + mu = &exchangeLock{} + s.exchangeLocks[subjectToken] = mu + } + s.exchangeMu.Unlock() + + mu.Lock() + defer mu.Unlock() + + if mu.done { + return mu.result, mu.err + } + + mu.result, mu.err = s.doExchange(ctx, subjectToken, fingerprint) + mu.done = true + + // The owner drops the entry on every exit path; waiters hold the pointer and read + // the cached result above even after it is gone. + s.exchangeMu.Lock() + delete(s.exchangeLocks, subjectToken) + s.exchangeMu.Unlock() + + return mu.result, mu.err +} + +// doExchange performs the exchange and caches the result on the session record. +func (s *Server) doExchange(ctx context.Context, subjectToken, fingerprint string) (*auth.Result, error) { + res, err := s.exchanger.Exchange(ctx, subjectToken) + if err != nil { + return nil, err + } + + if !s.exchanger.CacheEnabled() { + return res, nil + } + + if res.Expiry.IsZero() { + slog.Warn("exchanged token has no expiry (no expires_in and no exp claim) — " + + "caching skipped, so every upstream request will perform its own exchange") + return res, nil + } + + // Best-effort: a missing entry (BFF restarted mid-session) only costs a + // re-exchange next request, so it must not fail this one. + if sess, ok, _ := s.store.Get(ctx, subjectToken); ok { + sess.Exchanged = session.ExchangedToken{ + Token: res.AccessToken, + Expiry: res.Expiry, + Scopes: res.Scopes, + ConfigFingerprint: fingerprint, + } + if err := s.store.Put(ctx, sess); err != nil { + slog.Warn("failed to cache exchanged token on the session", "err", err) + } + } + return res, nil +} + +// writeExchangeError destroys the session on a rejection (it can never produce an +// upstream token) but keeps it on an unavailable IDP, which may recover — logging the +// user out over a transient blip would be self-inflicted. Neither response carries the +// IDP's reason; the exchanger already logged it. +func (s *Server) writeExchangeError(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, auth.ErrExchangeRejected) { + if s.store != nil { + if tok, ok := s.tokenFromCookie(r); ok { + _ = s.store.Delete(r.Context(), tok) + } + } + s.clearSessionCookie(w) + writeErrorJSON(w, http.StatusUnauthorized, "SESSION_EXPIRED", "session expired") + return + } + writeErrorJSON(w, http.StatusBadGateway, "UPSTREAM_UNAVAILABLE", "upstream temporarily unavailable") +} diff --git a/portals/ai-workspace/bff/internal/server/server.go b/portals/ai-workspace/bff/internal/server/server.go index 1a45dc9b72..bffc6d119d 100644 --- a/portals/ai-workspace/bff/internal/server/server.go +++ b/portals/ai-workspace/bff/internal/server/server.go @@ -20,6 +20,7 @@ package server import ( "context" + "log/slog" "net/http" "net/http/httputil" "net/url" @@ -54,8 +55,23 @@ type Server struct { proxy *httputil.ReverseProxy handler http.Handler + // exchanger is non-nil exactly when cfg.Auth.TokenExchangeEnabled(). + exchanger *auth.Exchanger + refreshMu sync.Mutex refreshLocks map[string]*refreshLock + + exchangeMu sync.Mutex + exchangeLocks map[string]*exchangeLock +} + +// exchangeLock single-flights one session's exchange, so the burst of parallel calls +// the SPA makes on page load hits the IDP once rather than once per request. +type exchangeLock struct { + sync.Mutex + done bool + result *auth.Result + err error } // New builds a Server from config. It creates the upstream HTTP client, the @@ -91,8 +107,9 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) { // The browser calls the proxy under the app's base path, so the prefix stripped // on the way upstream is the base path plus the proxy prefix — the Platform API // knows nothing about either. - proxy: proxy.ReverseProxy(target, paths.Base+paths.Proxy, transport), - refreshLocks: make(map[string]*refreshLock), + proxy: proxy.ReverseProxy(target, paths.Base+paths.Proxy, transport), + refreshLocks: make(map[string]*refreshLock), + exchangeLocks: make(map[string]*exchangeLock), } if cfg.Auth.OIDCEnabled() { @@ -109,6 +126,23 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) { return nil, err } s.oidc = o + + // Built after the OIDC client to reuse its endpoint discovery. + if cfg.Auth.TokenExchangeEnabled() { + endpoint := cfg.Auth.ExchangeTokenEndpoint() + if endpoint == "" { + endpoint = o.TokenEndpoint() + } + te := cfg.Auth.OIDC.TokenExchange + s.exchanger = auth.NewExchanger(upstream, te, endpoint) + slog.Info("token exchange enabled: upstream requests will carry an exchanged token", + "grant_type", te.GrantType, + "token_endpoint", endpoint, + "audience", te.Audience, + "resource", te.Resource, + "cache_enabled", te.CacheEnabled, + ) + } } s.handler = s.routes() diff --git a/portals/ai-workspace/bff/internal/server/token_exchange_test.go b/portals/ai-workspace/bff/internal/server/token_exchange_test.go new file mode 100644 index 0000000000..419dc9702c --- /dev/null +++ b/portals/ai-workspace/bff/internal/server/token_exchange_test.go @@ -0,0 +1,505 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the + * License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "ai-workspace-bff/internal/auth" + "ai-workspace-bff/internal/config" + "ai-workspace-bff/internal/paths" + "ai-workspace-bff/internal/proxy" + "ai-workspace-bff/internal/session" +) + +// exchangeTestHarness wires a Server with a token exchange pointed at a stub IDP and +// a stub Platform API, so a request can be driven end to end through handleProxy and +// the Authorization header that actually reached upstream can be asserted. +type exchangeTestHarness struct { + server *Server + idpCalls *atomic.Int32 + idpStatus func() (int, any) + upstreamGot *atomic.Value // last upstream Authorization header +} + +func newExchangeHarness(t *testing.T, cfgMut func(*config.TokenExchangeConfig)) *exchangeTestHarness { + t.Helper() + + h := &exchangeTestHarness{ + idpCalls: &atomic.Int32{}, + upstreamGot: &atomic.Value{}, + } + h.upstreamGot.Store("") + h.idpStatus = func() (int, any) { + return http.StatusOK, map[string]any{ + "access_token": "exchanged-token", + "issued_token_type": auth.TokenTypeAccessToken, + "token_type": "Bearer", + "expires_in": 3600, + "scope": "ap:project:read ap:gateway:read", + } + } + + // The stub IDP serves both the discovery document (so a real auth.OIDC client can + // be constructed — token exchange only runs in OIDC mode, and handleSession's user + // lookup takes a different branch without one) and the token endpoint. + mux := http.NewServeMux() + var idp *httptest.Server + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": idp.URL, + "authorization_endpoint": idp.URL + "/authorize", + "token_endpoint": idp.URL + "/token", + }) + }) + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + h.idpCalls.Add(1) + status, body := h.idpStatus() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if body != nil { + _ = json.NewEncoder(w).Encode(body) + } + }) + idp = httptest.NewServer(mux) + t.Cleanup(idp.Close) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h.upstreamGot.Store(r.Header.Get("Authorization")) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + t.Cleanup(upstream.Close) + + target, err := url.Parse(upstream.URL) + if err != nil { + t.Fatalf("parse upstream: %v", err) + } + + teCfg := config.TokenExchangeConfig{ + Enabled: true, + GrantType: config.GrantTokenExchange, + ClientID: "c", + ClientSecret: "s", + Audience: "platform-api", + Scopes: "ap:project:read ap:gateway:read", + SubjectTokenType: auth.TokenTypeJWT, + RequestedTokenType: auth.TokenTypeAccessToken, + CacheEnabled: true, + MinValidity: 60 * time.Second, + } + if cfgMut != nil { + cfgMut(&teCfg) + } + + cfg := &config.Config{ + Cookie: config.CookieConfig{Name: "_ai_workspace_session", Secure: true, SameSite: "lax"}, + Auth: config.AuthConfig{ + Mode: config.AuthModeOIDC, + OIDC: config.OIDCConfig{ + TokenExchange: teCfg, + }, + }, + Session: config.SessionConfig{IdleTimeout: 30 * time.Minute, AbsoluteTTL: 8 * time.Hour}, + } + + oidcClient, err := auth.NewOIDC( + context.Background(), idp.Client(), + idp.URL, "c", "s", + "https://localhost:9643"+paths.Base+"/api/auth/callback", "", "openid", + session.DefaultClaimMapping(), 8*time.Hour, + ) + if err != nil { + t.Fatalf("build oidc client: %v", err) + } + t.Cleanup(oidcClient.Close) + + h.server = &Server{ + cfg: cfg, + claims: session.DefaultClaimMapping(), + store: session.NewMemoryStore(), + oidc: oidcClient, + proxy: proxy.ReverseProxy(target, paths.Base+paths.Proxy, http.DefaultTransport), + exchanger: auth.NewExchanger(idp.Client(), teCfg, oidcClient.TokenEndpoint()), + refreshLocks: make(map[string]*refreshLock), + exchangeLocks: make(map[string]*exchangeLock), + } + t.Cleanup(func() { _ = h.server.store.Close() }) + return h +} + +// subjectSession seeds a session whose subject token is far from expiry, so the +// refresh path in handleProxy is never taken and only the exchange is exercised. +func (h *exchangeTestHarness) subjectSession(t *testing.T) string { + t.Helper() + const subject = "subject-token" + sess := &session.Session{ + ID: subject, + Mode: session.ModeOIDC, + AccessToken: subject, + RefreshToken: "refresh-token", + AccessExpiry: time.Now().Add(time.Hour), + AbsoluteExpiry: time.Now().Add(8 * time.Hour), + User: session.User{Name: "alice", Scopes: []string{"login-scope"}}, + } + if err := h.server.store.Put(context.Background(), sess); err != nil { + t.Fatalf("seed session: %v", err) + } + return subject +} + +func (h *exchangeTestHarness) proxyRequest(subject string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, paths.Base+paths.Proxy+"/api/v0.9/projects", nil) + req.AddCookie(&http.Cookie{Name: h.server.cfg.Cookie.Name, Value: subject}) + rec := httptest.NewRecorder() + h.server.handleProxy(rec, req) + return rec +} + +// TestProxyForwardsExchangedToken is the core behaviour: what reaches the Platform +// API is the exchanged token, never the login token. +func TestProxyForwardsExchangedToken(t *testing.T) { + h := newExchangeHarness(t, nil) + subject := h.subjectSession(t) + + rec := h.proxyRequest(subject) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if got := h.upstreamGot.Load().(string); got != "Bearer exchanged-token" { + t.Errorf("upstream Authorization = %q, want the exchanged token", got) + } +} + +// TestProxyFailsClosedWhenExchangeRejected is the security-critical guarantee: a +// refused exchange must never fall back to forwarding the unexchanged login token, +// which would reach the Platform API with the wrong audience and — on an IDP that +// mints no ap:* scopes — no platform authorization at all (GO-AUTH-001). +func TestProxyFailsClosedWhenExchangeRejected(t *testing.T) { + h := newExchangeHarness(t, nil) + subject := h.subjectSession(t) + h.idpStatus = func() (int, any) { + return http.StatusBadRequest, map[string]string{"error": "invalid_request"} + } + + rec := h.proxyRequest(subject) + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401 for a rejected exchange", rec.Code) + } + if got := h.upstreamGot.Load().(string); got != "" { + t.Errorf("upstream was called with %q — a rejected exchange must not reach the Platform API", got) + } + // A rejection means this session can never produce an upstream token, so it is + // cleared and the SPA is sent back through login. + if _, ok, _ := h.server.store.Get(context.Background(), subject); ok { + t.Error("a rejected session must be dropped from the store") + } +} + +// TestProxyFailsClosedWhenIDPUnavailable distinguishes a transient fault from a +// rejection: the user keeps their session and sees a 502, because logging them out +// over a token-endpoint blip is both wrong and self-inflicted. +func TestProxyFailsClosedWhenIDPUnavailable(t *testing.T) { + h := newExchangeHarness(t, nil) + subject := h.subjectSession(t) + h.idpStatus = func() (int, any) { + return http.StatusServiceUnavailable, nil + } + + rec := h.proxyRequest(subject) + if rec.Code != http.StatusBadGateway { + t.Errorf("status = %d, want 502 for an unavailable IDP", rec.Code) + } + if got := h.upstreamGot.Load().(string); got != "" { + t.Errorf("upstream was called with %q — a failed exchange must not reach the Platform API", got) + } + if _, ok, _ := h.server.store.Get(context.Background(), subject); !ok { + t.Error("a transient IDP failure must NOT destroy the session") + } +} + +// TestExchangeResponseLeaksNoIDPDetail: neither failure mode may tell the browser +// why the exchange failed — that maps out the deployment's trust configuration +// (error-handling.md directives 1 and 4). +func TestExchangeResponseLeaksNoIDPDetail(t *testing.T) { + for _, tc := range []struct { + name string + status int + body any + }{ + {"rejected", http.StatusBadRequest, map[string]string{ + "error": "invalid_target", "error_description": "audience platform-api is not registered"}}, + {"unavailable", http.StatusInternalServerError, map[string]string{ + "error": "server_error", "error_description": "internal database failure"}}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newExchangeHarness(t, nil) + subject := h.subjectSession(t) + h.idpStatus = func() (int, any) { return tc.status, tc.body } + + rec := h.proxyRequest(subject) + body := rec.Body.String() + for _, leak := range []string{"invalid_target", "server_error", "not registered", "database", "platform-api"} { + if strings.Contains(body, leak) { + t.Errorf("response body leaks IDP detail %q: %s", leak, body) + } + } + }) + } +} + +// TestExchangeIsCachedAcrossRequests: an exchange is a blocking round trip to the +// IDP, so repeating it per request would put the token endpoint in the path of every +// API call the SPA makes. +func TestExchangeIsCachedAcrossRequests(t *testing.T) { + h := newExchangeHarness(t, nil) + subject := h.subjectSession(t) + + for i := 0; i < 3; i++ { + if rec := h.proxyRequest(subject); rec.Code != http.StatusOK { + t.Fatalf("request %d: status %d", i, rec.Code) + } + } + if got := h.idpCalls.Load(); got != 1 { + t.Errorf("IDP called %d times for 3 requests, want 1 (cached)", got) + } +} + +// TestExchangeNotCachedWhenDisabled is the debugging escape hatch working as stated. +func TestExchangeNotCachedWhenDisabled(t *testing.T) { + h := newExchangeHarness(t, func(c *config.TokenExchangeConfig) { c.CacheEnabled = false }) + subject := h.subjectSession(t) + + for i := 0; i < 3; i++ { + if rec := h.proxyRequest(subject); rec.Code != http.StatusOK { + t.Fatalf("request %d: status %d", i, rec.Code) + } + } + if got := h.idpCalls.Load(); got != 3 { + t.Errorf("IDP called %d times, want 3 with caching off", got) + } +} + +// TestExchangeNotCachedWithoutExpiry: a token whose lifetime is unknown cannot be +// checked for freshness, so reusing it risks forwarding an expired credential. +func TestExchangeNotCachedWithoutExpiry(t *testing.T) { + h := newExchangeHarness(t, nil) + subject := h.subjectSession(t) + h.idpStatus = func() (int, any) { + return http.StatusOK, map[string]any{ + "access_token": "opaque-token-no-exp", + "issued_token_type": auth.TokenTypeAccessToken, + "token_type": "Bearer", + // no expires_in, and the token is opaque so there is no exp claim either + } + } + + for i := 0; i < 2; i++ { + if rec := h.proxyRequest(subject); rec.Code != http.StatusOK { + t.Fatalf("request %d: status %d", i, rec.Code) + } + } + if got := h.idpCalls.Load(); got != 2 { + t.Errorf("IDP called %d times, want 2 — a token with no known expiry must not be cached", got) + } +} + +// TestExchangeStaleFingerprintForcesReExchange: a cached token minted under a +// different audience/scope configuration must not be reused after a reconfiguration, +// or the change would be masked until every session expired. +func TestExchangeStaleFingerprintForcesReExchange(t *testing.T) { + h := newExchangeHarness(t, nil) + subject := h.subjectSession(t) + + sess, ok, _ := h.server.store.Get(context.Background(), subject) + if !ok { + t.Fatal("seeded session missing") + } + sess.Exchanged = session.ExchangedToken{ + Token: "token-from-old-config", + Expiry: time.Now().Add(time.Hour), + Scopes: []string{"ap:project:read"}, + ConfigFingerprint: "a-different-configuration", + } + if err := h.server.store.Put(context.Background(), sess); err != nil { + t.Fatalf("put: %v", err) + } + + if rec := h.proxyRequest(subject); rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + if got := h.idpCalls.Load(); got != 1 { + t.Errorf("IDP called %d times, want 1 — a stale fingerprint must force a re-exchange", got) + } + if got := h.upstreamGot.Load().(string); got != "Bearer exchanged-token" { + t.Errorf("upstream Authorization = %q, want the freshly exchanged token", got) + } +} + +// TestExchangeSingleFlight: the SPA fires a burst of parallel calls on page load, and +// they must collapse into one exchange rather than one per request. +func TestExchangeSingleFlight(t *testing.T) { + h := newExchangeHarness(t, nil) + subject := h.subjectSession(t) + + const parallel = 12 + var wg sync.WaitGroup + wg.Add(parallel) + for i := 0; i < parallel; i++ { + go func() { + defer wg.Done() + h.proxyRequest(subject) + }() + } + wg.Wait() + + if got := h.idpCalls.Load(); got != 1 { + t.Errorf("IDP called %d times for %d parallel requests, want 1", got, parallel) + } + // The single-flight map must not retain an entry once the group completes. + h.server.exchangeMu.Lock() + remaining := len(h.server.exchangeLocks) + h.server.exchangeMu.Unlock() + if remaining != 0 { + t.Errorf("exchangeLocks retained %d entries, want 0", remaining) + } +} + +// TestSessionReportsExchangedScopes is the payoff for a role-mode IDP: the UI gates +// on what the Platform API will authorize, which in exchange mode is decided by the +// exchanged token rather than the login token. +func TestSessionReportsExchangedScopes(t *testing.T) { + h := newExchangeHarness(t, nil) + subject := h.subjectSession(t) + + req := httptest.NewRequest(http.MethodGet, paths.Base+"/api/session", nil) + req.AddCookie(&http.Cookie{Name: h.server.cfg.Cookie.Name, Value: subject}) + rec := httptest.NewRecorder() + h.server.handleSession(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + var got struct { + User struct { + Scopes []string `json:"scopes"` + } `json:"user"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.User.Scopes) != 2 { + t.Fatalf("scopes = %v, want the two granted by the exchange", got.User.Scopes) + } + for _, s := range got.User.Scopes { + if s == "login-scope" { + t.Error("session reported the login token's scopes, not the exchanged token's") + } + } +} + +// TestUpstreamTokenPassthroughWithoutExchanger: with no exchange configured the login +// token is forwarded unchanged, so an existing deployment sees no behavior change. +func TestUpstreamTokenPassthroughWithoutExchanger(t *testing.T) { + s := &Server{ + cfg: &config.Config{}, + exchangeLocks: make(map[string]*exchangeLock), + } + got, err := s.upstreamToken(context.Background(), "login-token") + if err != nil { + t.Fatalf("upstreamToken: %v", err) + } + if got != "login-token" { + t.Errorf("upstreamToken = %q, want the login token unchanged", got) + } +} + +// TestRefreshDropsExchangedToken: the exchanged token was derived from the subject +// token that just rotated, so it must not survive a refresh — otherwise a derived +// credential outlives the one it was minted from. +func TestRefreshDropsExchangedToken(t *testing.T) { + h := newExchangeHarness(t, nil) + subject := h.subjectSession(t) + + // Prime the cache. + if rec := h.proxyRequest(subject); rec.Code != http.StatusOK { + t.Fatalf("priming request: status %d", rec.Code) + } + sess, ok, _ := h.server.store.Get(context.Background(), subject) + if !ok || sess.Exchanged.Token == "" { + t.Fatal("expected a cached exchanged token after the first request") + } + + // A rotated session record is built fresh by SessionFromToken, so Exchanged must + // come back zero rather than being copied forward. + rotated := &session.Session{ + ID: "new-subject-token", + Mode: session.ModeOIDC, + AccessToken: "new-subject-token", + AccessExpiry: time.Now().Add(time.Hour), + AbsoluteExpiry: sess.AbsoluteExpiry, + User: sess.User, + } + if rotated.Exchanged.Token != "" { + t.Error("a rotated session must not carry the previous exchanged token") + } + if rotated.Exchanged.Usable(time.Now(), time.Minute, h.server.exchanger.ConfigFingerprint()) { + t.Error("a zero ExchangedToken must never report itself usable") + } +} + +// TestExchangedTokenUsable pins the cache-validity rules, each of which exists to +// stop a specific unsafe reuse. +func TestExchangedTokenUsable(t *testing.T) { + const fp = "fingerprint" + now := time.Now() + + for _, tc := range []struct { + name string + tok session.ExchangedToken + want bool + }{ + {"fresh", session.ExchangedToken{ + Token: "t", Expiry: now.Add(time.Hour), ConfigFingerprint: fp}, true}, + {"empty token", session.ExchangedToken{ + Expiry: now.Add(time.Hour), ConfigFingerprint: fp}, false}, + {"unknown expiry", session.ExchangedToken{ + Token: "t", ConfigFingerprint: fp}, false}, + {"already expired", session.ExchangedToken{ + Token: "t", Expiry: now.Add(-time.Minute), ConfigFingerprint: fp}, false}, + {"inside the renewal window", session.ExchangedToken{ + Token: "t", Expiry: now.Add(30 * time.Second), ConfigFingerprint: fp}, false}, + {"stale fingerprint", session.ExchangedToken{ + Token: "t", Expiry: now.Add(time.Hour), ConfigFingerprint: "other"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := tc.tok.Usable(now, time.Minute, fp); got != tc.want { + t.Errorf("Usable = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/portals/ai-workspace/bff/internal/session/store.go b/portals/ai-workspace/bff/internal/session/store.go index a111920e14..b43065d666 100644 --- a/portals/ai-workspace/bff/internal/session/store.go +++ b/portals/ai-workspace/bff/internal/session/store.go @@ -60,6 +60,32 @@ type Session struct { AccessExpiry time.Time // from exp claim / expires_in (read, not verified) AbsoluteExpiry time.Time // hard cap User User + Exchanged ExchangedToken +} + +// ExchangedToken is a cached token-exchange result; the zero value is a cache miss. +// Scopes is what /api/session reports in exchange mode, since the exchanged token is +// what the Platform API authorizes. +type ExchangedToken struct { + // Token, not AccessToken: Session.AccessToken is the login token, and confusing + // the two is precisely what this feature exists to prevent. + Token string + Expiry time.Time + Scopes []string + ConfigFingerprint string +} + +// Usable reports whether the cached token can still be forwarded upstream. An unknown +// expiry is never usable: freshness cannot be checked, so reusing it would risk +// forwarding an expired credential. +func (e ExchangedToken) Usable(now time.Time, minValidity time.Duration, fingerprint string) bool { + if e.Token == "" || e.Expiry.IsZero() { + return false + } + if e.ConfigFingerprint != fingerprint { + return false + } + return now.Add(minValidity).Before(e.Expiry) } // Expired reports whether the session has passed its absolute lifetime. diff --git a/portals/ai-workspace/configs/config-template.toml b/portals/ai-workspace/configs/config-template.toml index 541808c473..8c6e03da70 100644 --- a/portals/ai-workspace/configs/config-template.toml +++ b/portals/ai-workspace/configs/config-template.toml @@ -377,6 +377,93 @@ post_logout_redirect_url = "https://localhost:9643/ai-workspace/login" # restrict, and always keep offline_access or token refresh breaks. scope = "openid profile email offline_access ap:api_key:read ap:organization:read ap:organization:manage ap:organization:subscription:read ap:project:read ap:project:create ap:project:update ap:project:delete ap:project:manage ap:application:read ap:application:create ap:application:update ap:application:delete ap:application:manage ap:application:api_key:read ap:application:api_key:create ap:application:api_key:delete ap:application:api_key:manage ap:application:association:read ap:application:association:create ap:application:association:delete ap:application:association:manage ap:application:association:api_key:read ap:gateway:read ap:gateway:create ap:gateway:update ap:gateway:delete ap:gateway:manage ap:gateway:token:read ap:gateway:token:create ap:gateway:token:delete ap:gateway:token:manage ap:gateway_custom_policy:read ap:gateway_custom_policy:create ap:gateway_custom_policy:delete ap:gateway_custom_policy:manage ap:gateway:artifact:read ap:gateway:manifest:read ap:llm_template:read ap:llm_template:create ap:llm_template:update ap:llm_template:delete ap:llm_template:manage ap:llm_provider:read ap:llm_provider:create ap:llm_provider:update ap:llm_provider:delete ap:llm_provider:manage ap:llm_provider:api_key:read ap:llm_provider:api_key:create ap:llm_provider:api_key:delete ap:llm_provider:api_key:manage ap:llm_provider:deployment:read ap:llm_provider:deployment:create ap:llm_provider:deployment:delete ap:llm_provider:deployment:manage ap:llm_provider:deployment:undeploy ap:llm_provider:deployment:restore ap:llm_proxy:read ap:llm_proxy:create ap:llm_proxy:update ap:llm_proxy:delete ap:llm_proxy:manage ap:llm_proxy:api_key:read ap:llm_proxy:api_key:create ap:llm_proxy:api_key:delete ap:llm_proxy:api_key:manage ap:llm_proxy:deployment:read ap:llm_proxy:deployment:create ap:llm_proxy:deployment:delete ap:llm_proxy:deployment:manage ap:llm_proxy:deployment:undeploy ap:llm_proxy:deployment:restore ap:mcp_proxy:read ap:mcp_proxy:create ap:mcp_proxy:update ap:mcp_proxy:delete ap:mcp_proxy:manage ap:mcp_proxy:deployment:read ap:mcp_proxy:deployment:create ap:mcp_proxy:deployment:delete ap:mcp_proxy:deployment:manage ap:mcp_proxy:deployment:undeploy ap:mcp_proxy:deployment:restore ap:secret:read ap:secret:create ap:secret:update ap:secret:delete ap:secret:manage" +# ==================================================================== +# Token exchange (RFC 8693 / RFC 7523 on-behalf-of) +# +# Trade the login token issued by [ai_workspace.auth.oidc] above for one minted +# specifically for the Platform API, so the credential the AI Workspace sends +# upstream is audience- and scope-scoped to that one API instead of being the broad +# session token the browser's login produced. +# +# This is a child table of [ai_workspace.auth.oidc] because it describes a SECOND +# token call against the same issuer: client_id, client_secret and scope all default +# to the login values above, so a single-application deployment sets only `enabled` +# and `audience`. It is its own table, rather than more keys on the parent, because +# an STS commonly registers the exchange as a separate OAuth application with its own +# client_id — the two calls are different clients, not one client twice. +# +# WHY YOU MIGHT WANT THIS +# [ai_workspace.auth.authorization] exists because some enterprise IDPs authenticate +# users perfectly well but cannot mint this platform's ap:* scopes — Microsoft Entra +# ID is the documented case. The workaround is mode = "role" plus the same +# role-to-scope grant table mounted into two services, kept in sync by hand. +# +# An exchange removes that mirror. The corporate IDP proves who the user is; an STS +# that CAN mint ap:* scopes (WSO2 Identity Server, Asgardeo) issues the token the +# Platform API actually authorizes. Both sides then read the scopes off one token, +# and [auth.authorization] mode can go back to "scope" on both. +# +# FAIL-CLOSED +# With enabled = true, a failed exchange fails the request — the AI Workspace never +# falls back to forwarding the unexchanged login token, which would carry the wrong +# audience and, on a role-mode IDP, no platform authorization at all. A +# misconfiguration therefore takes the UI down rather than silently downgrading it, +# and is validated at startup wherever possible. +# +# MUST MATCH THE PLATFORM API +# The issued token's aud must be accepted by [platform_api.auth.idp] audience in this +# same config.toml, and its iss by [platform_api.auth.idp] issuer. The Platform API +# needs no code change for this — only those two values. +# ==================================================================== +[ai_workspace.auth.oidc.token_exchange] + +# Off by default: the AI Workspace forwards the login token upstream exactly as it +# did before this feature existed. Requires [ai_workspace.auth] mode = "oidc" — in +# basic mode the JWT held is one the Platform API signed for itself, so there is no +# subject token to exchange and startup fails rather than ignoring the setting. +enabled = false +grant_type = "token_exchange" + +# Where to post the exchange. Empty (the default) reuses the token endpoint already +# discovered from `authority` on the parent table, which is correct when the same IDP +# both authenticates and exchanges. +token_endpoint = "" + +# Credentials the AI Workspace authenticates with at the exchange endpoint +client_id = "" +client_secret = "" + +# The target the issued token is minted for; becomes its aud claim. Set this to the +# value [platform_api.auth.idp] audience carries, or the Platform API rejects every +# request. +# ONE value only, and it must be pre-registered on the exchanging application +audience = "" +resource = "" + +# Scopes requested on the issued token (space-separated). +scope = "" + +# How the IDP should interpret the token being exchanged. The default is the JWT +# type. +subject_token_type = "urn:ietf:params:oauth:token-type:jwt" + +# The token type asked for back. +requested_token_type = "urn:ietf:params:oauth:token-type:access_token" + +# Reuse the issued token across requests until it nears expiry, instead of exchanging +# on every proxied call — an exchange is a blocking round trip, and repeating it per +# request would put the IDP's token endpoint in the path of every API call the UI +# makes. The cached token is bound to one session and to this exact +# (grant, audience, resource, scope) configuration, and is dropped whenever the login +# token rotates, so a cache hit can never widen privilege or serve a token minted for +# a different target. Turn it off only to debug an exchange. +cache_enabled = true + +# How long before expiry a cached token is re-exchanged, absorbing clock skew and the +# upstream request's own duration so a token cannot expire in flight. Must be +# positive when cache_enabled = true. +min_validity = "60s" + # ==================================================================== # Settings with no config key