diff --git a/.github/workflows/portal-frontend-ci.yml b/.github/workflows/portal-frontend-ci.yml index 62f719b0..3a5851d6 100644 --- a/.github/workflows/portal-frontend-ci.yml +++ b/.github/workflows/portal-frontend-ci.yml @@ -40,6 +40,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Audit production dependencies + run: pnpm security:audit + - name: Lint run: pnpm lint diff --git a/portal/backend/.env.example b/portal/backend/.env.example index 1c3a61ef..41dcdb2b 100644 --- a/portal/backend/.env.example +++ b/portal/backend/.env.example @@ -18,8 +18,8 @@ BFF_ENV=development # CORS (frontend integration) BFF_CORS__ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 BFF_CORS__ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS -BFF_CORS__ALLOWED_HEADERS=Content-Type,X-Correlation-ID -BFF_CORS__ALLOW_CREDENTIALS=false +BFF_CORS__ALLOWED_HEADERS=Content-Type,Authorization,X-Correlation-ID +BFF_CORS__ALLOW_CREDENTIALS=true # Proxy BFF_PROXY__OPENFGC_API_URL=http://localhost:8060 @@ -32,4 +32,38 @@ BFF_PROXY__ALLOWED_PASSTHROUGH_METHODS=["GET","POST","PUT","DELETE"] BFF_PROXY__PLACEHOLDER_MODE_ENABLED=false BFF_PROXY__PLACEHOLDER_USER_ID= BFF_PROXY__PLACEHOLDER_ORG_ID= -BFF_PROXY__PLACEHOLDER_GROUP_ID= + +# OIDC confidential-client authentication +BFF_AUTH__ENABLED=false +BFF_AUTH__ISSUER_URL= +BFF_AUTH__CLIENT_ID= +# Environment only; never place this secret in a YAML config file. +BFF_AUTH__CLIENT_SECRET= +BFF_AUTH__PORTAL_URL=http://localhost:5173/consents +BFF_AUTH__REDIRECT_URI=http://localhost:8080/auth/callback +BFF_AUTH__POST_LOGOUT_REDIRECT_URI=http://localhost:5173/ +BFF_AUTH__SCOPES=openid email profile portal:consents:read:self portal:consents:write:self +BFF_AUTH__RESOURCE_AUDIENCE= +BFF_AUTH__ALLOWED_SIGNING_ALGORITHMS=RS256 +BFF_AUTH__HTTP_TIMEOUT=5s +BFF_AUTH__REFRESH_TIMEOUT=10s +BFF_AUTH__CLOCK_SKEW=30s +BFF_AUTH__SCOPE_CLAIM=scope +BFF_AUTH__ORG_ID_CLAIM=org_id +BFF_AUTH__REQUIRE_ACCESS_TOKEN_TYPE=false +BFF_AUTH__ACCESS_TOKEN_TYPE_CLAIM=token_type +BFF_AUTH__ACCESS_TOKEN_TYPE_VALUE=access_token +BFF_AUTH__ACCESS_TOKEN_PART1_COOKIE=portal-at-p1 +BFF_AUTH__ACCESS_TOKEN_PART2_COOKIE=portal-at-p2 +BFF_AUTH__REFRESH_TOKEN_PART1_COOKIE=portal-rt-p1 +BFF_AUTH__REFRESH_TOKEN_PART2_COOKIE=portal-rt-p2 +BFF_AUTH__ID_TOKEN_PART1_COOKIE=portal-id-p1 +BFF_AUTH__ID_TOKEN_PART2_COOKIE=portal-id-p2 +BFF_AUTH__OAUTH_STATE_COOKIE=portal-oauth-state +BFF_AUTH__PKCE_VERIFIER_COOKIE=portal-pkce-verifier +BFF_AUTH__COOKIE_SECURE=false +BFF_AUTH__COOKIE_SAME_SITE=Lax +BFF_AUTH__LOGIN_TRANSACTION_MAX_AGE_SECONDS=600 +BFF_AUTH__REFRESH_COOKIE_MAX_AGE_SECONDS=86400 +BFF_AUTH__MAX_TOKEN_PART_BYTES=3800 +BFF_AUTH__MAX_RECONSTRUCTED_TOKEN_BYTES=7600 diff --git a/portal/backend/README.md b/portal/backend/README.md index a5e0df66..7e459da7 100644 --- a/portal/backend/README.md +++ b/portal/backend/README.md @@ -61,7 +61,7 @@ When credentials are enabled, origins must be explicitly allowlisted (wildcard o Portal-facing user endpoints: -- `GET /me/consents` -> upstream `GET /api/v1/consents` with forced `userIds=` and `details=true` +- `GET /me/consents` -> upstream `GET /api/v1/consents` with forced `userIds=` and `details=true` - `GET /me/consents/{consentId}` -> one upstream `GET /api/v1/consents/{consentId}?details=true`; the detailed consent snapshot is returned unchanged - `POST /me/consents/{consentId}/approve` -> BFF fetches the current consent with `details=true`, validates selected optional elements against its embedded stable IDs and versions, automatically approves mandatory elements, preserves existing approvals, and sends a full upstream consent update with the consent's immutable `groupId` as the trusted `group-id` header - `POST /me/consents/{consentId}/revoke` -> upstream `POST /api/v1/consents/{consentId}/revoke` @@ -75,6 +75,16 @@ Proxy hardening: - Request body limit enforcement (`BFF_PROXY__MAX_REQUEST_BYTES`) with `413` - Deterministic upstream error mapping: timeout -> `504`, other connectivity failures -> `502` +Authentication endpoints: + +- `GET /auth/login` starts confidential-client OIDC login +- `GET /auth/callback` validates returned tokens through discovery/JWKS and issues split cookies +- `POST /auth/refresh` reconstructs the split refresh token and returns `204` +- `POST /auth/logout` clears cookies and returns the configured or discovered logout URL + +Set `BFF_AUTH__ENABLED=true` to use OIDC authentication. The client secret is read only from +`BFF_AUTH__CLIENT_SECRET`; see `.env.example` for the complete configuration contract. + Error contract for proxy-originated failures: ```json @@ -98,7 +108,6 @@ Common error codes: - `BFF_PROXY__PLACEHOLDER_MODE_ENABLED=true` is blocked when `BFF_ENV=production` - `BFF_PROXY__PLACEHOLDER_USER_ID` must be empty if placeholder mode is disabled - `BFF_PROXY__PLACEHOLDER_ORG_ID` must be empty if placeholder mode is disabled -- `BFF_PROXY__PLACEHOLDER_GROUP_ID` must be empty if placeholder mode is disabled ## AI Instructions diff --git a/portal/backend/cmd/server/servicemanager.go b/portal/backend/cmd/server/servicemanager.go index 21afb452..22dd68ef 100644 --- a/portal/backend/cmd/server/servicemanager.go +++ b/portal/backend/cmd/server/servicemanager.go @@ -19,11 +19,13 @@ package main import ( + "context" "log/slog" "net/http" "github.com/wso2/openfgc/portal/backend/internal/me" "github.com/wso2/openfgc/portal/backend/internal/proxy" + "github.com/wso2/openfgc/portal/backend/internal/system/auth" "github.com/wso2/openfgc/portal/backend/internal/system/config" "github.com/wso2/openfgc/portal/backend/internal/system/healthcheck" ) @@ -35,12 +37,19 @@ func registerServices(mux *http.ServeMux, log *slog.Logger, cfg config.Config) e mux.HandleFunc("GET /health", healthHandler.Liveness) log.Debug("registered health endpoints") - if err := proxy.Initialize(mux, cfg.Proxy); err != nil { + authManager, err := auth.NewManager(context.Background(), cfg.Auth, cfg.Proxy, log) + if err != nil { + return err + } + authManager.RegisterRoutes(mux) + log.Debug("registered auth module") + + if err := proxy.Initialize(mux, cfg.Proxy, authManager); err != nil { return err } log.Debug("registered proxy module") - if err := me.Initialize(mux, cfg); err != nil { + if err := me.Initialize(mux, cfg, authManager); err != nil { return err } log.Debug("registered me module") diff --git a/portal/backend/go.mod b/portal/backend/go.mod index d6b2d4f3..072354d5 100644 --- a/portal/backend/go.mod +++ b/portal/backend/go.mod @@ -3,10 +3,13 @@ module github.com/wso2/openfgc/portal/backend go 1.26.3 require ( + github.com/coreos/go-oidc/v3 v3.20.0 + github.com/go-jose/go-jose/v4 v4.1.4 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/env v1.0.0 github.com/knadh/koanf/providers/file v1.2.0 github.com/knadh/koanf/v2 v2.3.0 + golang.org/x/oauth2 v0.36.0 ) require ( diff --git a/portal/backend/go.sum b/portal/backend/go.sum index b061e312..9e06b21f 100644 --- a/portal/backend/go.sum +++ b/portal/backend/go.sum @@ -1,7 +1,11 @@ +github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE= +github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= @@ -28,6 +32,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= diff --git a/portal/backend/internal/me/handler.go b/portal/backend/internal/me/handler.go index 9fdd8c78..9d74f274 100644 --- a/portal/backend/internal/me/handler.go +++ b/portal/backend/internal/me/handler.go @@ -80,10 +80,10 @@ func (h *Handler) ConsentByID(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed") return } - if _, ok := h.resolveUserID(w, r); !ok { + userID, ok := h.resolveUserID(w, r) + if !ok { return } - // TODO(Phase 3): verify the fetched consent belongs to the authenticated user before returning it. consentID := r.PathValue("consentId") if consentID == "" { writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent id not found") @@ -94,12 +94,27 @@ func (h *Handler) ConsentByID(w http.ResponseWriter, r *http.Request) { return } - if err := h.svc.Forward(w, r, http.MethodGet, "/api/v1/consents/"+url.PathEscape(consentID), func(q url.Values) { + baseResp, err := h.svc.ForwardRaw(r, http.MethodGet, "/api/v1/consents/"+url.PathEscape(consentID), func(q url.Values) { q.Set("details", "true") q.Del("includeStatusHistory") - }, nil); err != nil { + }, nil) + if err != nil { writeProxyError(w, err) + return + } + if baseResp.StatusCode != http.StatusOK { + h.svc.WriteUpstreamResponse(w, baseResp) + return + } + if _, owned, ownershipErr := OwnedConsentGroupID(baseResp.Body, userID); ownershipErr != nil { + writeProxyError(w, ownershipErr) + return + } else if !owned { + writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent not found") + return } + + h.svc.WriteUpstreamResponse(w, baseResp) } // ConsentApprove handles POST /me/consents/{consentId}/approve. @@ -112,7 +127,6 @@ func (h *Handler) ConsentApprove(w http.ResponseWriter, r *http.Request) { if !ok { return } - // TODO(Phase 3): verify the fetched consent belongs to the authenticated user before approving it. consentID := r.PathValue("consentId") if consentID == "" { writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent id not found") @@ -144,6 +158,13 @@ func (h *Handler) ConsentApprove(w http.ResponseWriter, r *http.Request) { h.svc.WriteUpstreamResponse(w, baseResp) return } + if _, owned, ownershipErr := OwnedConsentGroupID(baseResp.Body, userID); ownershipErr != nil { + writeProxyError(w, ownershipErr) + return + } else if !owned { + writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent not found") + return + } payload, trustedGroupID, err := h.svc.BuildApprovalUpdatePayload(baseResp.Body, selections, userID) if err != nil { @@ -169,7 +190,6 @@ func (h *Handler) ConsentRevoke(w http.ResponseWriter, r *http.Request) { if !ok { return } - // TODO(Phase 3): verify the targeted consent belongs to the authenticated user before revoking it. consentID := r.PathValue("consentId") if consentID == "" { writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent id not found") @@ -189,7 +209,25 @@ func (h *Handler) ConsentRevoke(w http.ResponseWriter, r *http.Request) { writeJSONError(w, http.StatusBadRequest, "INVALID_PAYLOAD", "invalid request payload") return } - if err := h.svc.Forward(w, r, http.MethodPost, "/api/v1/consents/"+url.PathEscape(consentID)+"/revoke", nil, payload); err != nil { + baseResp, err := h.svc.ForwardRaw(r, http.MethodGet, "/api/v1/consents/"+url.PathEscape(consentID), nil, nil) + if err != nil { + writeProxyError(w, err) + return + } + if baseResp.StatusCode != http.StatusOK { + h.svc.WriteUpstreamResponse(w, baseResp) + return + } + trustedGroupID, owned, err := OwnedConsentGroupID(baseResp.Body, userID) + if err != nil { + writeProxyError(w, err) + return + } + if !owned { + writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent not found") + return + } + if err := h.svc.ForwardWithGroupID(w, r, http.MethodPost, "/api/v1/consents/"+url.PathEscape(consentID)+"/revoke", nil, payload, trustedGroupID); err != nil { writeProxyError(w, err) } } @@ -225,12 +263,12 @@ func writeBodyReadError(w http.ResponseWriter, err error) { } func (h *Handler) resolveUserID(w http.ResponseWriter, r *http.Request) (string, bool) { - userID, ok := systemcontext.UserIDFromContext(r.Context()) + principal, ok := systemcontext.PrincipalFromContext(r.Context()) if !ok { writeJSONError(w, http.StatusServiceUnavailable, "PLACEHOLDER_UNAVAILABLE", "placeholder identity unavailable") return "", false } - return userID, true + return principal.UserID, true } func (h *Handler) readBoundedBody(r *http.Request) ([]byte, error) { diff --git a/portal/backend/internal/me/init.go b/portal/backend/internal/me/init.go index a764c195..6fe8aaec 100644 --- a/portal/backend/internal/me/init.go +++ b/portal/backend/internal/me/init.go @@ -21,27 +21,21 @@ package me import ( "net/http" + "github.com/wso2/openfgc/portal/backend/internal/system/auth" "github.com/wso2/openfgc/portal/backend/internal/system/config" - "github.com/wso2/openfgc/portal/backend/internal/system/middleware" ) // Initialize sets up the me module and registers routes. -func Initialize(mux *http.ServeMux, cfg config.Config) error { +func Initialize(mux *http.ServeMux, cfg config.Config, authManager *auth.Manager) error { handler, err := NewHandler(cfg.Proxy) if err != nil { return err } - userIDOptions := middleware.UserIDOptions{ - PlaceholderModeEnabled: cfg.Proxy.PlaceholderModeEnabled, - PlaceholderUserID: cfg.Proxy.PlaceholderUserID, - Environment: cfg.Env, - } - - mux.Handle("GET /me/consents", middleware.UserID(http.HandlerFunc(handler.Consents), userIDOptions)) - mux.Handle("GET /me/consents/{consentId}", middleware.UserID(http.HandlerFunc(handler.ConsentByID), userIDOptions)) - mux.Handle("POST /me/consents/{consentId}/approve", middleware.UserID(http.HandlerFunc(handler.ConsentApprove), userIDOptions)) - mux.Handle("POST /me/consents/{consentId}/revoke", middleware.UserID(http.HandlerFunc(handler.ConsentRevoke), userIDOptions)) + mux.Handle("GET /me/consents", authManager.Require(http.HandlerFunc(handler.Consents), auth.ScopeConsentsReadSelf)) + mux.Handle("GET /me/consents/{consentId}", authManager.Require(http.HandlerFunc(handler.ConsentByID), auth.ScopeConsentsReadSelf)) + mux.Handle("POST /me/consents/{consentId}/approve", authManager.Require(http.HandlerFunc(handler.ConsentApprove), auth.ScopeConsentsWriteSelf)) + mux.Handle("POST /me/consents/{consentId}/revoke", authManager.Require(http.HandlerFunc(handler.ConsentRevoke), auth.ScopeConsentsWriteSelf)) return nil } diff --git a/portal/backend/internal/me/service.go b/portal/backend/internal/me/service.go index 3c0ff095..4f92b809 100644 --- a/portal/backend/internal/me/service.go +++ b/portal/backend/internal/me/service.go @@ -143,6 +143,21 @@ func (s *Service) ForwardRaw(r *http.Request, upstreamMethod, upstreamPath strin return s.proxy.ForwardRaw(r, upstreamMethod, upstreamPath, queryMutator, body) } +// OwnedConsentGroupID verifies consent ownership and returns its trusted group ID. +func OwnedConsentGroupID(body []byte, userID string) (string, bool, error) { + var consent consentRetrievalResponse + if err := json.Unmarshal(body, &consent); err != nil { + return "", false, proxy.ErrUpstreamUnavailable + } + userID = strings.TrimSpace(userID) + for _, authorization := range consent.Authorizations { + if authorization.UserID != nil && strings.TrimSpace(*authorization.UserID) == userID { + return strings.TrimSpace(consent.GroupID), true, nil + } + } + return "", false, nil +} + // WriteUpstreamResponse writes an upstream response back to the caller. func (s *Service) WriteUpstreamResponse(w http.ResponseWriter, resp *proxy.UpstreamResponse) { _ = s.proxy.WriteUpstreamResponse(w, resp) diff --git a/portal/backend/internal/proxy/init.go b/portal/backend/internal/proxy/init.go index fcb88062..8fd9bf79 100644 --- a/portal/backend/internal/proxy/init.go +++ b/portal/backend/internal/proxy/init.go @@ -21,16 +21,17 @@ package proxy import ( "net/http" + "github.com/wso2/openfgc/portal/backend/internal/system/auth" "github.com/wso2/openfgc/portal/backend/internal/system/config" ) // Initialize sets up the proxy module and registers routes. -func Initialize(mux *http.ServeMux, cfg config.ProxyConfig) error { +func Initialize(mux *http.ServeMux, cfg config.ProxyConfig, authManager *auth.Manager) error { handler, err := NewHandler(cfg) if err != nil { return err } - mux.HandleFunc("/api/{path...}", handler.API) + mux.Handle("/api/{path...}", authManager.RequireAPI(http.HandlerFunc(handler.API))) return nil } diff --git a/portal/backend/internal/proxy/service.go b/portal/backend/internal/proxy/service.go index d44c3d50..03598b64 100644 --- a/portal/backend/internal/proxy/service.go +++ b/portal/backend/internal/proxy/service.go @@ -32,6 +32,7 @@ import ( "strings" "github.com/wso2/openfgc/portal/backend/internal/system/config" + systemcontext "github.com/wso2/openfgc/portal/backend/internal/system/context" "github.com/wso2/openfgc/portal/backend/internal/system/correlation" ) @@ -374,13 +375,11 @@ func connectionHeaderNames(headers http.Header) map[string]struct{} { } func (s *Service) setTrustedHeaders(incoming *http.Request, outgoing *http.Request, trustedGroupID string) { - if s.cfg.PlaceholderOrgID != "" { - outgoing.Header.Set("org-id", s.cfg.PlaceholderOrgID) + if principal, ok := systemcontext.PrincipalFromContext(incoming.Context()); ok { + outgoing.Header.Set("org-id", principal.OrgID) } if trustedGroupID != "" { outgoing.Header.Set("group-id", trustedGroupID) - } else if s.cfg.PlaceholderGroupID != "" { - outgoing.Header.Set("group-id", s.cfg.PlaceholderGroupID) } correlationID := incoming.Header.Get("X-Correlation-ID") if correlationID == "" { diff --git a/portal/backend/internal/proxy/service_test.go b/portal/backend/internal/proxy/service_test.go index 5d0a7d16..1c9f33f5 100644 --- a/portal/backend/internal/proxy/service_test.go +++ b/portal/backend/internal/proxy/service_test.go @@ -28,6 +28,7 @@ import ( "time" "github.com/wso2/openfgc/portal/backend/internal/system/config" + systemcontext "github.com/wso2/openfgc/portal/backend/internal/system/context" ) func TestNewServiceRejectsInvalidUpstreamURL(t *testing.T) { @@ -69,7 +70,6 @@ func TestCheckAPIAccess(t *testing.T) { MaxResponseBytes: 1024, AllowedPassthrough: []string{"GET", "POST", "PUT", "DELETE"}, PlaceholderOrgID: "ORG-001", - PlaceholderGroupID: "GROUP-001", }) if err != nil { t.Fatalf("failed to construct service: %v", err) @@ -153,6 +153,7 @@ func TestForwardRawMapsBodyReadFailureToUpstreamUnavailable(t *testing.T) { } req := httptest.NewRequest(http.MethodGet, "http://bff.local/api/consents", nil) + req = req.WithContext(systemcontext.WithPrincipal(req.Context(), systemcontext.Principal{UserID: "user-1", OrgID: "ORG-001", Scopes: map[string]struct{}{}})) _, err = svc.ForwardRaw(req, http.MethodGet, "/api/v1/consents", nil, nil) if !errors.Is(err, ErrUpstreamUnavailable) { t.Fatalf("expected ErrUpstreamUnavailable, got: %v", err) @@ -366,8 +367,8 @@ func TestForwardStripsUntrustedIdentityHeaders(t *testing.T) { if got := r.Header.Get("org-id"); got != "ORG-001" { t.Fatalf("expected trusted org-id, got %q", got) } - if got := r.Header.Get("group-id"); got != "GROUP-001" { - t.Fatalf("expected trusted group-id, got %q", got) + if got := r.Header.Get("group-id"); got != "" { + t.Fatalf("expected untrusted group-id to be removed, got %q", got) } if got := r.Header.Get("TPP-client-id"); got != "" { t.Fatalf("expected legacy client header to be stripped, got %q", got) @@ -383,13 +384,13 @@ func TestForwardStripsUntrustedIdentityHeaders(t *testing.T) { MaxResponseBytes: 1024, AllowedPassthrough: []string{"GET"}, PlaceholderOrgID: "ORG-001", - PlaceholderGroupID: "GROUP-001", }) if err != nil { t.Fatalf("failed to construct service: %v", err) } req := httptest.NewRequest(http.MethodGet, "http://bff.local/api/consents", nil) + req = req.WithContext(systemcontext.WithPrincipal(req.Context(), systemcontext.Principal{UserID: "user-1", OrgID: "ORG-001", Scopes: map[string]struct{}{}})) req.Header.Set("org-id", "MALICIOUS-ORG") req.Header.Set("group-id", "MALICIOUS-GROUP") req.Header.Set("TPP-client-id", "MALICIOUS-CLIENT") diff --git a/portal/backend/internal/system/auth/auth_test_helpers_test.go b/portal/backend/internal/system/auth/auth_test_helpers_test.go new file mode 100644 index 00000000..c810f90d --- /dev/null +++ b/portal/backend/internal/system/auth/auth_test_helpers_test.go @@ -0,0 +1,91 @@ +/* + * 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 ( + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + + "github.com/wso2/openfgc/portal/backend/internal/system/config" +) + +func testAuthConfig(issuer string) config.AuthConfig { + return config.AuthConfig{ + Enabled: true, IssuerURL: issuer, ClientID: "portal-client", ClientSecret: "secret", + PortalURL: "http://portal.example/consents", RedirectURI: "http://bff.example/auth/callback", + PostLogoutRedirectURI: "http://portal.example/", Scopes: []string{"openid", "profile"}, + ResourceAudience: "portal-api", AllowedSigningAlgorithms: []string{"RS256"}, + HTTPTimeout: 5 * time.Second, RefreshTimeout: 5 * time.Second, ClockSkew: 30 * time.Second, + ScopeClaim: "scope", OrgIDClaim: "org_id", AccessTokenPart1Cookie: "portal-at-p1", + AccessTokenPart2Cookie: "portal-at-p2", RefreshTokenPart1Cookie: "portal-rt-p1", + RefreshTokenPart2Cookie: "portal-rt-p2", IDTokenPart1Cookie: "portal-id-p1", + IDTokenPart2Cookie: "portal-id-p2", OAuthStateCookie: "portal-oauth-state", + PKCEVerifierCookie: "portal-pkce-verifier", CookieSameSite: "Lax", + LoginTransactionMaxAgeSeconds: 600, RefreshCookieMaxAgeSeconds: 3600, + MaxTokenPartBytes: 3800, MaxReconstructedTokenBytes: 7600, + } +} + +func signTestToken(t *testing.T, key *rsa.PrivateKey, keyID string, claims map[string]any) string { + t.Helper() + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key}, (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", keyID)) + if err != nil { + t.Fatal(err) + } + serialized, err := jwt.Signed(signer).Claims(claims).Serialize() + if err != nil { + t.Fatal(err) + } + return serialized +} + +func writeTestJSON(w http.ResponseWriter, value any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(value) +} + +func cookiesByName(cookies []*http.Cookie) map[string]*http.Cookie { + result := make(map[string]*http.Cookie, len(cookies)) + for _, cookie := range cookies { + result[cookie.Name] = cookie + } + return result +} + +func startLoginTransaction(t *testing.T, manager *Manager) (string, map[string]*http.Cookie) { + t.Helper() + recorder := httptest.NewRecorder() + manager.Login(recorder, httptest.NewRequest(http.MethodGet, "/auth/login", nil)) + if recorder.Code != http.StatusFound { + t.Fatalf("login returned %d", recorder.Code) + } + location, err := url.Parse(recorder.Header().Get("Location")) + if err != nil || location.Query().Get("state") == "" { + t.Fatalf("login did not return state: %q", recorder.Header().Get("Location")) + } + return location.Query().Get("state"), cookiesByName(recorder.Result().Cookies()) +} diff --git a/portal/backend/internal/system/auth/handlers.go b/portal/backend/internal/system/auth/handlers.go new file mode 100644 index 00000000..fc8745c2 --- /dev/null +++ b/portal/backend/internal/system/auth/handlers.go @@ -0,0 +1,249 @@ +/* + * 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" + "io" + "mime" + "net/http" + "net/url" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +type errorResponse struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// Login starts the OIDC authorization-code flow. +func (m *Manager) Login(w http.ResponseWriter, r *http.Request) { + if !m.cfg.Enabled { + writeError(w, http.StatusNotFound, "NOT_FOUND", "route not found") + return + } + state, verifier, err := newLoginTransaction() + if err != nil { + m.log.Error("OIDC login transaction generation failed") + writeError(w, http.StatusInternalServerError, "LOGIN_FAILED", "login failed") + return + } + m.setLoginTransactionCookies(w, state, verifier) + authorizationURL := m.oauthConfig.AuthCodeURL(state, pkceAuthorizationOption(verifier)) + http.Redirect(w, r, authorizationURL, http.StatusFound) +} + +// Callback exchanges an authorization code and issues split-token cookies. +func (m *Manager) Callback(w http.ResponseWriter, r *http.Request) { + if !m.cfg.Enabled { + writeError(w, http.StatusNotFound, "NOT_FOUND", "route not found") + return + } + states := r.URL.Query()["state"] + expectedState, stateErr := exactlyOneCookie(r, m.cfg.OAuthStateCookie) + verifier, verifierErr := exactlyOneCookie(r, m.cfg.PKCEVerifierCookie) + m.clearLoginTransactionCookies(w) + if len(states) != 1 || !statesMatch(expectedState, states[0]) || stateErr != nil || + verifierErr != nil || !validPKCEVerifier(verifier) { + m.callbackFailure(w, r, "invalid login transaction") + return + } + codes := r.URL.Query()["code"] + if len(codes) != 1 || strings.TrimSpace(codes[0]) == "" { + m.callbackFailure(w, r, "missing authorization code") + return + } + ctx, cancel := timeBoundOIDCContext(r, m.httpClient, m.cfg.HTTPTimeout) + defer cancel() + token, err := m.oauthConfig.Exchange(ctx, codes[0], oauth2.VerifierOption(verifier)) + if err != nil { + m.callbackFailure(w, r, "token exchange failed") + return + } + idToken, _ := token.Extra("id_token").(string) + if token.AccessToken == "" || token.RefreshToken == "" || idToken == "" { + m.callbackFailure(w, r, "token response incomplete") + return + } + _, accessExpiry, err := m.validateAccessToken(r.Context(), token.AccessToken) + if err != nil { + m.callbackFailure(w, r, "access token validation failed") + return + } + idExpiry, err := m.validateIDToken(r.Context(), idToken) + if err != nil { + m.callbackFailure(w, r, "id token validation failed") + return + } + refreshExpiry := time.Now().Add(time.Duration(m.cfg.RefreshCookieMaxAgeSeconds) * time.Second) + if err := m.validateCookieSet(token.AccessToken, token.RefreshToken, idToken); err != nil { + m.callbackFailure(w, r, "token cookie limits exceeded") + return + } + _ = m.setSplitCookies(w, token.AccessToken, m.cfg.AccessTokenPart1Cookie, m.cfg.AccessTokenPart2Cookie, true, accessExpiry) + _ = m.setSplitCookies(w, token.RefreshToken, m.cfg.RefreshTokenPart1Cookie, m.cfg.RefreshTokenPart2Cookie, true, refreshExpiry) + _ = m.setSplitCookies(w, idToken, m.cfg.IDTokenPart1Cookie, m.cfg.IDTokenPart2Cookie, false, idExpiry) + http.Redirect(w, r, m.cfg.PortalURL, http.StatusFound) +} + +// Refresh exchanges the reconstructed refresh token and rotates token cookies. +func (m *Manager) Refresh(w http.ResponseWriter, r *http.Request) { + if !m.cfg.Enabled { + writeError(w, http.StatusNotFound, "NOT_FOUND", "route not found") + return + } + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || mediaType != "application/x-www-form-urlencoded" { + writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "form-encoded refresh token required") + return + } + // Allow for URL percent-encoding expansion, then enforce the decoded part limit below. + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, int64(m.cfg.MaxTokenPartBytes*3+64))) + if err != nil { + writeError(w, http.StatusBadRequest, "INVALID_REQUEST", "invalid refresh request") + return + } + values, err := url.ParseQuery(string(body)) + parts := values["refresh_token"] + if err != nil || len(parts) != 1 || parts[0] == "" || len(values) != 1 { + writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid refresh credentials") + return + } + part2, err := exactlyOneCookie(r, m.cfg.RefreshTokenPart2Cookie) + if err != nil { + writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid refresh credentials") + return + } + refreshToken, err := reconstructToken(parts[0], part2, m.cfg) + if err != nil { + writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid refresh credentials") + return + } + ctx, cancel := timeBoundOIDCContext(r, m.httpClient, m.cfg.RefreshTimeout) + defer cancel() + source := m.oauthConfig.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken, Expiry: time.Now().Add(-time.Hour)}) + newToken, err := source.Token() + if err != nil || newToken.AccessToken == "" { + m.clearTokenCookies(w) + writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "session refresh failed") + return + } + _, accessExpiry, err := m.validateAccessToken(r.Context(), newToken.AccessToken) + if err != nil { + m.clearTokenCookies(w) + writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "session refresh failed") + return + } + idToken, _ := newToken.Extra("id_token").(string) + var idExpiry time.Time + if idToken != "" { + idExpiry, err = m.validateIDToken(r.Context(), idToken) + if err != nil { + m.clearTokenCookies(w) + writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "session refresh failed") + return + } + } + if _, _, err := splitToken(newToken.AccessToken, m.cfg.MaxTokenPartBytes); err != nil { + m.clearTokenCookies(w) + writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "session refresh failed") + return + } + _ = m.setSplitCookies(w, newToken.AccessToken, m.cfg.AccessTokenPart1Cookie, m.cfg.AccessTokenPart2Cookie, true, accessExpiry) + if newToken.RefreshToken != "" && newToken.RefreshToken != refreshToken { + if _, _, err := splitToken(newToken.RefreshToken, m.cfg.MaxTokenPartBytes); err != nil { + m.clearTokenCookies(w) + writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "session refresh failed") + return + } + refreshExpiry := time.Now().Add(time.Duration(m.cfg.RefreshCookieMaxAgeSeconds) * time.Second) + _ = m.setSplitCookies(w, newToken.RefreshToken, m.cfg.RefreshTokenPart1Cookie, m.cfg.RefreshTokenPart2Cookie, true, refreshExpiry) + } + if idToken != "" { + if _, _, err := splitToken(idToken, m.cfg.MaxTokenPartBytes); err != nil { + m.clearTokenCookies(w) + writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "session refresh failed") + return + } + _ = m.setSplitCookies(w, idToken, m.cfg.IDTokenPart1Cookie, m.cfg.IDTokenPart2Cookie, false, idExpiry) + } + w.WriteHeader(http.StatusNoContent) +} + +// Logout clears local cookies and returns the IdP end-session URL when available. +func (m *Manager) Logout(w http.ResponseWriter, r *http.Request) { + m.clearTokenCookies(w) + logoutURL := m.cfg.PostLogoutRedirectURI + if m.endSessionEndpoint != "" { + if parsed, err := url.Parse(m.endSessionEndpoint); err == nil { + query := parsed.Query() + query.Set("post_logout_redirect_uri", m.cfg.PostLogoutRedirectURI) + if idToken, tokenErr := joinCookieToken(r, m.cfg.IDTokenPart1Cookie, m.cfg.IDTokenPart2Cookie, m.cfg); tokenErr == nil { + if _, verifyErr := m.validateIDToken(r.Context(), idToken); verifyErr == nil { + query.Set("id_token_hint", idToken) + } + } + parsed.RawQuery = query.Encode() + logoutURL = parsed.String() + } + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"logoutUrl": logoutURL}) +} + +func (m *Manager) callbackFailure(w http.ResponseWriter, r *http.Request, reason string) { + m.log.Warn("OIDC callback rejected", "reason", reason) + target, err := url.Parse(m.cfg.PortalURL) + if err != nil { + writeError(w, http.StatusUnauthorized, "LOGIN_FAILED", "login failed") + return + } + query := target.Query() + query.Set("auth_error", "login_failed") + target.RawQuery = query.Encode() + http.Redirect(w, r, target.String(), http.StatusFound) +} + +func (m *Manager) validateCookieSet(accessToken, refreshToken, idToken string) error { + for _, token := range []string{accessToken, refreshToken, idToken} { + if len(token) > m.cfg.MaxReconstructedTokenBytes { + return errInvalidCredentials + } + if _, _, err := splitToken(token, m.cfg.MaxTokenPartBytes); err != nil { + return err + } + } + return nil +} + +func timeBoundOIDCContext(r *http.Request, client *http.Client, timeout time.Duration) (context.Context, context.CancelFunc) { + ctx, cancel := context.WithTimeout(r.Context(), timeout) + return oidc.ClientContext(ctx, client), cancel +} + +func writeError(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(errorResponse{Code: code, Message: message}) +} diff --git a/portal/backend/internal/system/auth/handlers_test.go b/portal/backend/internal/system/auth/handlers_test.go new file mode 100644 index 00000000..9edba2c4 --- /dev/null +++ b/portal/backend/internal/system/auth/handlers_test.go @@ -0,0 +1,580 @@ +/* + * 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 ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + + "github.com/wso2/openfgc/portal/backend/internal/system/config" +) + +func TestOIDCLoginCallbackRefreshAndLogout(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + keyID := "test-key" + var issuer *httptest.Server + var refreshCalls int + var expectedPKCEChallenge string + issuer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + writeTestJSON(w, map[string]any{ + "issuer": issuer.URL, "authorization_endpoint": issuer.URL + "/authorize", + "token_endpoint": issuer.URL + "/token", "jwks_uri": issuer.URL + "/jwks", + "end_session_endpoint": issuer.URL + "/logout", + "id_token_signing_alg_values_supported": []string{"RS256"}, + }) + case "/jwks": + writeTestJSON(w, jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{ + Key: &privateKey.PublicKey, KeyID: keyID, Algorithm: "RS256", Use: "sig", + }}}) + case "/token": + if err := r.ParseForm(); err != nil { + t.Errorf("parse token form: %v", err) + } + if clientID, clientSecret, ok := r.BasicAuth(); !ok || clientID != "portal-client" || clientSecret != "secret" { + t.Errorf("unexpected confidential-client authentication") + } + if r.Form.Get("grant_type") != "refresh_token" { + verifier := r.Form.Get("code_verifier") + if !validPKCEVerifier(verifier) || pkceChallenge(verifier) != expectedPKCEChallenge { + t.Errorf("authorization-code exchange did not contain the matching PKCE verifier") + } + } + now := time.Now() + access := signTestToken(t, privateKey, keyID, map[string]any{ + "iss": issuer.URL, "sub": "user-1", "aud": "portal-api", "exp": now.Add(time.Hour).Unix(), + "iat": now.Unix(), "nbf": now.Add(-time.Minute).Unix(), "org_id": "org-1", + "scope": ScopeConsentsReadSelf + " " + ScopeConsentsWriteSelf, + }) + idToken := signTestToken(t, privateKey, keyID, map[string]any{ + "iss": issuer.URL, "sub": "user-1", "aud": "portal-client", "exp": now.Add(time.Hour).Unix(), "iat": now.Unix(), + }) + refresh := "refresh-token-initial" + if r.Form.Get("grant_type") == "refresh_token" { + refreshCalls++ + refresh = "refresh-token-rotated" + } + writeTestJSON(w, map[string]any{ + "access_token": access, "refresh_token": refresh, "id_token": idToken, + "token_type": "Bearer", "expires_in": 3600, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer issuer.Close() + + cfg := testAuthConfig(issuer.URL) + manager, err := NewManager(context.Background(), cfg, config.ProxyConfig{}, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("new manager: %v", err) + } + mux := http.NewServeMux() + manager.RegisterRoutes(mux) + + login := httptest.NewRecorder() + mux.ServeHTTP(login, httptest.NewRequest(http.MethodGet, "/auth/login", nil)) + authorizationURL, parseErr := url.Parse(login.Header().Get("Location")) + if login.Code != http.StatusFound || parseErr != nil { + t.Fatalf("unexpected login redirect: %d %s", login.Code, login.Header().Get("Location")) + } + state := authorizationURL.Query().Get("state") + expectedPKCEChallenge = authorizationURL.Query().Get("code_challenge") + if state == "" || expectedPKCEChallenge == "" || authorizationURL.Query().Get("code_challenge_method") != "S256" { + t.Fatalf("login redirect missing state or S256 PKCE parameters: %s", authorizationURL.RawQuery) + } + transactionCookies := cookiesByName(login.Result().Cookies()) + for _, name := range []string{cfg.OAuthStateCookie, cfg.PKCEVerifierCookie} { + cookie := transactionCookies[name] + if cookie == nil || !cookie.HttpOnly || cookie.Path != "/" || cookie.MaxAge != cfg.LoginTransactionMaxAgeSeconds { + t.Fatalf("invalid login transaction cookie %s: %#v", name, cookie) + } + } + + callback := httptest.NewRecorder() + callbackRequest := httptest.NewRequest(http.MethodGet, "/auth/callback?code=test-code&state="+url.QueryEscape(state), nil) + callbackRequest.AddCookie(transactionCookies[cfg.OAuthStateCookie]) + callbackRequest.AddCookie(transactionCookies[cfg.PKCEVerifierCookie]) + mux.ServeHTTP(callback, callbackRequest) + if callback.Code != http.StatusFound || callback.Header().Get("Location") != cfg.PortalURL { + t.Fatalf("unexpected callback: %d %s", callback.Code, callback.Header().Get("Location")) + } + cookies := cookiesByName(callback.Result().Cookies()) + for _, name := range []string{cfg.OAuthStateCookie, cfg.PKCEVerifierCookie} { + if cookies[name] == nil || cookies[name].MaxAge != -1 { + t.Fatalf("callback did not consume login transaction cookie %s", name) + } + } + for _, name := range []string{cfg.AccessTokenPart1Cookie, cfg.AccessTokenPart2Cookie, cfg.RefreshTokenPart1Cookie, cfg.RefreshTokenPart2Cookie, cfg.IDTokenPart1Cookie, cfg.IDTokenPart2Cookie} { + if cookies[name] == nil { + t.Fatalf("missing cookie %s", name) + } + } + if cookies[cfg.AccessTokenPart1Cookie].HttpOnly || !cookies[cfg.AccessTokenPart2Cookie].HttpOnly || cookies[cfg.IDTokenPart2Cookie].HttpOnly { + t.Fatal("unexpected split-cookie visibility") + } + protectedRequest := httptest.NewRequest(http.MethodGet, "/protected", nil) + protectedRequest.Header.Set("Authorization", "Bearer "+cookies[cfg.AccessTokenPart1Cookie].Value) + protectedRequest.AddCookie(cookies[cfg.AccessTokenPart2Cookie]) + forbidden := httptest.NewRecorder() + manager.Require(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), ScopeElementsWrite).ServeHTTP(forbidden, protectedRequest) + if forbidden.Code != http.StatusForbidden { + t.Fatalf("expected missing scope to return 403, got %d", forbidden.Code) + } + + refreshPart := cookies[cfg.RefreshTokenPart1Cookie].Value + refreshRequest := httptest.NewRequest(http.MethodPost, "/auth/refresh", strings.NewReader(url.Values{"refresh_token": {refreshPart}}.Encode())) + refreshRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + refreshRequest.AddCookie(cookies[cfg.RefreshTokenPart2Cookie]) + refresh := httptest.NewRecorder() + mux.ServeHTTP(refresh, refreshRequest) + if refresh.Code != http.StatusNoContent || refreshCalls != 1 { + t.Fatalf("unexpected refresh: status=%d calls=%d", refresh.Code, refreshCalls) + } + + logoutRequest := httptest.NewRequest(http.MethodPost, "/auth/logout", nil) + logoutRequest.Header.Set("Authorization", "Bearer "+cookies[cfg.AccessTokenPart1Cookie].Value) + for _, cookie := range cookies { + logoutRequest.AddCookie(cookie) + } + logout := httptest.NewRecorder() + mux.ServeHTTP(logout, logoutRequest) + if logout.Code != http.StatusOK { + t.Fatalf("unexpected logout status: %d", logout.Code) + } + var payload map[string]string + if err := json.Unmarshal(logout.Body.Bytes(), &payload); err != nil || !strings.HasPrefix(payload["logoutUrl"], issuer.URL+"/logout?") { + t.Fatalf("unexpected logout payload: %s", logout.Body.String()) + } +} + +func TestCallbackFailureMappingAndRedaction(t *testing.T) { + tests := []struct { + name string + requestURL string + token http.HandlerFunc + maxPart int + }{ + {name: "missing code", requestURL: "/auth/callback"}, + {name: "duplicate code", requestURL: "/auth/callback?code=secret-code&code=second"}, + { + name: "token endpoint rejection", requestURL: "/auth/callback?code=secret-code", + token: func(w http.ResponseWriter, _ *http.Request) { + writeTestJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid_grant", "error_description": "sensitive detail"}) + }, + }, + { + name: "incomplete token response", requestURL: "/auth/callback?code=secret-code", + token: func(w http.ResponseWriter, _ *http.Request) { + writeTestJSON(w, map[string]any{"access_token": "access", "token_type": "Bearer"}) + }, + }, + { + name: "cookie limit failure", requestURL: "/auth/callback?code=secret-code", maxPart: 8, + token: func(w http.ResponseWriter, r *http.Request) { + p := providerFromRequestContext(t, r) + writeValidTokenResponse(t, w, p, "refresh-token", true) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + provider := newOIDCValidationProvider(t) + if test.token != nil { + provider.token = func(w http.ResponseWriter, r *http.Request) { + r = r.WithContext(context.WithValue(r.Context(), validationProviderContextKey{}, provider)) + test.token(w, r) + } + } + var logs bytes.Buffer + cfg := testAuthConfig(provider.server.URL) + if test.maxPart > 0 { + cfg.MaxTokenPartBytes = test.maxPart + } + manager, err := NewManager(context.Background(), cfg, config.ProxyConfig{}, slog.New(slog.NewTextHandler(&logs, nil))) + if err != nil { + t.Fatal(err) + } + recorder := httptest.NewRecorder() + state, transactionCookies := startLoginTransaction(t, manager) + request := httptest.NewRequest(http.MethodGet, test.requestURL, nil) + query := request.URL.Query() + query.Set("state", state) + request.URL.RawQuery = query.Encode() + request.AddCookie(transactionCookies[cfg.OAuthStateCookie]) + request.AddCookie(transactionCookies[cfg.PKCEVerifierCookie]) + manager.Callback(recorder, request) + if recorder.Code != http.StatusFound { + t.Fatalf("expected generic redirect, got %d", recorder.Code) + } + location, err := url.Parse(recorder.Header().Get("Location")) + if err != nil || location.Query().Get("auth_error") != "login_failed" { + t.Fatalf("unexpected failure location: %q", recorder.Header().Get("Location")) + } + if strings.Contains(recorder.Body.String(), "secret-code") || strings.Contains(logs.String(), "secret-code") || strings.Contains(logs.String(), "sensitive detail") { + t.Fatalf("callback leaked sensitive details: body=%q logs=%q", recorder.Body.String(), logs.String()) + } + }) + } +} + +func TestCallbackRejectsMissingMismatchedAndReplayedState(t *testing.T) { + provider := newOIDCValidationProvider(t) + var tokenCalls atomic.Int32 + provider.token = func(w http.ResponseWriter, _ *http.Request) { + tokenCalls.Add(1) + writeValidTokenResponse(t, w, provider, "refresh-token", true) + } + manager := provider.manager(t, nil) + + for _, test := range []struct { + name string + state func(string) []string + }{ + {name: "missing state", state: func(string) []string { return nil }}, + {name: "mismatched state", state: func(string) []string { return []string{"different-state"} }}, + {name: "duplicate state", state: func(value string) []string { return []string{value, value} }}, + } { + t.Run(test.name, func(t *testing.T) { + state, cookies := startLoginTransaction(t, manager) + request := httptest.NewRequest(http.MethodGet, "/auth/callback?code=test-code", nil) + query := request.URL.Query() + query["state"] = test.state(state) + request.URL.RawQuery = query.Encode() + request.AddCookie(cookies[manager.cfg.OAuthStateCookie]) + request.AddCookie(cookies[manager.cfg.PKCEVerifierCookie]) + + recorder := httptest.NewRecorder() + manager.Callback(recorder, request) + + assertCallbackFailure(t, recorder) + assertTransactionCookiesCleared(t, recorder, manager.cfg) + }) + } + + state, cookies := startLoginTransaction(t, manager) + newRequest := func() *http.Request { + request := httptest.NewRequest(http.MethodGet, "/auth/callback?code=test-code&state="+url.QueryEscape(state), nil) + request.AddCookie(cookies[manager.cfg.OAuthStateCookie]) + request.AddCookie(cookies[manager.cfg.PKCEVerifierCookie]) + return request + } + success := httptest.NewRecorder() + manager.Callback(success, newRequest()) + if success.Code != http.StatusFound || success.Header().Get("Location") != manager.cfg.PortalURL { + t.Fatalf("valid callback failed: %d %q", success.Code, success.Header().Get("Location")) + } + + replay := httptest.NewRecorder() + replayRequest := httptest.NewRequest(http.MethodGet, "/auth/callback?code=test-code&state="+url.QueryEscape(state), nil) + manager.Callback(replay, replayRequest) + assertCallbackFailure(t, replay) + if tokenCalls.Load() != 1 { + t.Fatalf("replayed callback reached token endpoint; calls=%d", tokenCalls.Load()) + } +} + +func assertCallbackFailure(t *testing.T, recorder *httptest.ResponseRecorder) { + t.Helper() + if recorder.Code != http.StatusFound { + t.Fatalf("expected callback failure redirect, got %d", recorder.Code) + } + location, err := url.Parse(recorder.Header().Get("Location")) + if err != nil || location.Query().Get("auth_error") != "login_failed" { + t.Fatalf("unexpected callback failure location: %q", recorder.Header().Get("Location")) + } +} + +func assertTransactionCookiesCleared(t *testing.T, recorder *httptest.ResponseRecorder, cfg config.AuthConfig) { + t.Helper() + cookies := cookiesByName(recorder.Result().Cookies()) + for _, name := range []string{cfg.OAuthStateCookie, cfg.PKCEVerifierCookie} { + if cookies[name] == nil || cookies[name].MaxAge != -1 || !cookies[name].HttpOnly { + t.Fatalf("transaction cookie %s was not securely consumed: %#v", name, cookies[name]) + } + } +} + +func TestRefreshRequestValidation(t *testing.T) { + manager := &Manager{cfg: config.AuthConfig{ + Enabled: true, RefreshTokenPart2Cookie: "rt2", MaxTokenPartBytes: 32, MaxReconstructedTokenBytes: 64, + }} + tests := []struct { + name string + contentType string + body string + cookie string + wantStatus int + }{ + {name: "wrong content type", contentType: "application/json", body: `{}`, wantStatus: http.StatusBadRequest}, + {name: "missing readable part", contentType: "application/x-www-form-urlencoded", body: "", cookie: "rt2=part2", wantStatus: http.StatusUnauthorized}, + {name: "duplicate readable part", contentType: "application/x-www-form-urlencoded", body: "refresh_token=a&refresh_token=b", cookie: "rt2=part2", wantStatus: http.StatusUnauthorized}, + {name: "extra form field", contentType: "application/x-www-form-urlencoded", body: "refresh_token=a&extra=b", cookie: "rt2=part2", wantStatus: http.StatusUnauthorized}, + {name: "missing secure part", contentType: "application/x-www-form-urlencoded", body: "refresh_token=part1", wantStatus: http.StatusUnauthorized}, + {name: "duplicate secure part", contentType: "application/x-www-form-urlencoded", body: "refresh_token=part1", cookie: "rt2=a; rt2=b", wantStatus: http.StatusUnauthorized}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/auth/refresh", strings.NewReader(test.body)) + r.Header.Set("Content-Type", test.contentType) + if test.cookie != "" { + r.Header.Set("Cookie", test.cookie) + } + recorder := httptest.NewRecorder() + manager.Refresh(recorder, r) + if recorder.Code != test.wantStatus { + t.Fatalf("got %d, want %d", recorder.Code, test.wantStatus) + } + }) + } +} + +func TestRefreshRotationRetentionFailureAndAtomicCookies(t *testing.T) { + tests := []struct { + name string + refreshReturned string + invalidGrant bool + invalidIDToken bool + wantStatus int + wantRefreshCookie bool + wantClears bool + }{ + {name: "rotated refresh token", refreshReturned: "refresh-rotated", wantStatus: http.StatusNoContent, wantRefreshCookie: true}, + {name: "retain existing refresh token", wantStatus: http.StatusNoContent}, + {name: "invalid or expired refresh token", invalidGrant: true, wantStatus: http.StatusUnauthorized, wantClears: true}, + {name: "invalid new ID token clears atomically", refreshReturned: "refresh-rotated", invalidIDToken: true, wantStatus: http.StatusUnauthorized, wantClears: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + provider := newOIDCValidationProvider(t) + provider.token = func(w http.ResponseWriter, _ *http.Request) { + if test.invalidGrant { + writeTestJSONStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid_grant"}) + return + } + writeRefreshResponse(t, w, provider, test.refreshReturned, test.invalidIDToken) + } + manager := provider.manager(t, nil) + recorder := performRefresh(t, manager, "refresh-original") + if recorder.Code != test.wantStatus { + t.Fatalf("got %d, want %d: %s", recorder.Code, test.wantStatus, recorder.Body.String()) + } + cookies := recorder.Result().Cookies() + refreshCookieFound := false + clearCount := 0 + for _, cookie := range cookies { + if cookie.Name == manager.cfg.RefreshTokenPart1Cookie && cookie.MaxAge > 0 { + refreshCookieFound = true + } + if cookie.MaxAge == -1 { + clearCount++ + } + } + if refreshCookieFound != test.wantRefreshCookie { + t.Fatalf("refresh cookie emitted=%v, want %v", refreshCookieFound, test.wantRefreshCookie) + } + if test.wantClears && clearCount != 6 { + t.Fatalf("expected all six cookies to be cleared, got %d", clearCount) + } + if test.wantClears { + assertLastCookieForEachNameClears(t, cookies) + } + }) + } +} + +func TestConcurrentRefreshWithNonRotatingToken(t *testing.T) { + provider := newOIDCValidationProvider(t) + var calls atomic.Int32 + provider.token = func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + writeRefreshResponse(t, w, provider, "", false) + } + manager := provider.manager(t, nil) + + const requestCount = 4 + statuses := make(chan int, requestCount) + var wait sync.WaitGroup + for range requestCount { + wait.Add(1) + go func() { + defer wait.Done() + statuses <- performRefresh(t, manager, "refresh-original").Code + }() + } + wait.Wait() + close(statuses) + for status := range statuses { + if status != http.StatusNoContent { + t.Errorf("concurrent refresh returned %d", status) + } + } + if calls.Load() != requestCount { + t.Fatalf("expected %d independent refresh exchanges, got %d", requestCount, calls.Load()) + } +} + +func TestCookieOnlyLogoutRejectedAndSuccessfulLogoutClearsCookies(t *testing.T) { + provider := newOIDCValidationProvider(t) + manager := provider.manager(t, nil) + mux := http.NewServeMux() + manager.RegisterRoutes(mux) + + cookieOnly := httptest.NewRequest(http.MethodPost, "/auth/logout", nil) + cookieOnly.AddCookie(&http.Cookie{Name: manager.cfg.AccessTokenPart2Cookie, Value: "part2"}) + rejected := httptest.NewRecorder() + mux.ServeHTTP(rejected, cookieOnly) + if rejected.Code != http.StatusUnauthorized || rejected.Header().Get("WWW-Authenticate") != "Bearer" { + t.Fatalf("cookie-only logout was not rejected correctly: %d", rejected.Code) + } + + now := time.Now() + access := signTestToken(t, provider.key1, "key-1", map[string]any{ + "iss": provider.server.URL, "sub": "user", "aud": "portal-api", "exp": now.Add(time.Hour).Unix(), + "org_id": "org", "scope": ScopeConsentsReadSelf, + }) + part1, part2, err := splitToken(access, manager.cfg.MaxTokenPartBytes) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/auth/logout", nil) + request.Header.Set("Authorization", "Bearer "+part1) + request.AddCookie(&http.Cookie{Name: manager.cfg.AccessTokenPart2Cookie, Value: part2}) + recorder := httptest.NewRecorder() + mux.ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("logout returned %d: %s", recorder.Code, recorder.Body.String()) + } + assertLastCookieForEachNameClears(t, recorder.Result().Cookies()) + var payload map[string]string + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil || payload["logoutUrl"] != manager.cfg.PostLogoutRedirectURI { + t.Fatalf("expected post-logout fallback URL, got %s", recorder.Body.String()) + } +} + +type validationProviderContextKey struct{} + +func providerFromRequestContext(t *testing.T, r *http.Request) *oidcValidationProvider { + t.Helper() + provider, ok := r.Context().Value(validationProviderContextKey{}).(*oidcValidationProvider) + if !ok { + t.Fatal("validation provider missing from request context") + } + return provider +} + +func writeValidTokenResponse(t *testing.T, w http.ResponseWriter, provider *oidcValidationProvider, refresh string, includeID bool) { + t.Helper() + now := time.Now() + response := map[string]any{ + "access_token": signTestToken(t, provider.key1, "key-1", map[string]any{ + "iss": provider.server.URL, "sub": "user", "aud": "portal-api", "exp": now.Add(time.Hour).Unix(), + "org_id": "org", "scope": ScopeConsentsReadSelf, + }), + "refresh_token": refresh, "token_type": "Bearer", "expires_in": 3600, + } + if includeID { + response["id_token"] = signTestToken(t, provider.key1, "key-1", map[string]any{ + "iss": provider.server.URL, "sub": "user", "aud": "portal-client", "exp": now.Add(time.Hour).Unix(), + }) + } + writeTestJSON(w, response) +} + +func writeRefreshResponse(t *testing.T, w http.ResponseWriter, provider *oidcValidationProvider, refresh string, invalidID bool) { + t.Helper() + now := time.Now() + response := map[string]any{ + "access_token": signTestToken(t, provider.key1, "key-1", map[string]any{ + "iss": provider.server.URL, "sub": "user", "aud": "portal-api", "exp": now.Add(time.Hour).Unix(), + "org_id": "org", "scope": ScopeConsentsReadSelf, + }), + "token_type": "Bearer", "expires_in": 3600, + } + if refresh != "" { + response["refresh_token"] = refresh + } + if invalidID { + response["id_token"] = "not-a-jwt" + } else { + response["id_token"] = signTestToken(t, provider.key1, "key-1", map[string]any{ + "iss": provider.server.URL, "sub": "user", "aud": "portal-client", "exp": now.Add(time.Hour).Unix(), + }) + } + writeTestJSON(w, response) +} + +func performRefresh(t *testing.T, manager *Manager, refreshToken string) *httptest.ResponseRecorder { + t.Helper() + part1, part2, err := splitToken(refreshToken, manager.cfg.MaxTokenPartBytes) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodPost, "/auth/refresh", strings.NewReader(url.Values{"refresh_token": {part1}}.Encode())) + request.Header.Set("Content-Type", "application/x-www-form-urlencoded") + request.AddCookie(&http.Cookie{Name: manager.cfg.RefreshTokenPart2Cookie, Value: part2}) + recorder := httptest.NewRecorder() + manager.Refresh(recorder, request) + return recorder +} + +func assertLastCookieForEachNameClears(t *testing.T, cookies []*http.Cookie) { + t.Helper() + last := make(map[string]*http.Cookie) + for _, cookie := range cookies { + last[cookie.Name] = cookie + } + if len(last) != 6 { + t.Fatalf("expected all six token cookie names, got %d", len(last)) + } + for name, cookie := range last { + if cookie.MaxAge != -1 || cookie.Value != "" || cookie.Path != "/" { + t.Fatalf("cookie %s was not finally cleared: %#v", name, cookie) + } + } +} + +func writeTestJSONStatus(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/portal/backend/internal/system/auth/manager.go b/portal/backend/internal/system/auth/manager.go new file mode 100644 index 00000000..5db7d00e --- /dev/null +++ b/portal/backend/internal/system/auth/manager.go @@ -0,0 +1,280 @@ +/* + * 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 implements the BFF's OIDC client and split-token authentication boundary. +package auth + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" + + "github.com/wso2/openfgc/portal/backend/internal/system/config" + systemcontext "github.com/wso2/openfgc/portal/backend/internal/system/context" +) + +var errInvalidCredentials = errors.New("invalid credentials") + +// Manager owns OIDC discovery, token validation, auth routes, and authorization middleware. +type Manager struct { + cfg config.AuthConfig + proxyCfg config.ProxyConfig + log *slog.Logger + httpClient *http.Client + oauthConfig oauth2.Config + accessVerifier *oidc.IDTokenVerifier + idVerifier *oidc.IDTokenVerifier + endSessionEndpoint string +} + +// NewManager initializes OIDC discovery when auth is enabled. +func NewManager(ctx context.Context, cfg config.AuthConfig, proxyCfg config.ProxyConfig, log *slog.Logger) (*Manager, error) { + m := &Manager{cfg: cfg, proxyCfg: proxyCfg, log: log} + if !cfg.Enabled { + return m, nil + } + if err := validateConfiguredScopes(cfg.Scopes); err != nil { + return nil, err + } + m.httpClient = &http.Client{Timeout: cfg.HTTPTimeout} + discoveryContext := oidc.ClientContext(ctx, m.httpClient) + provider, err := oidc.NewProvider(discoveryContext, cfg.IssuerURL) + if err != nil { + return nil, err + } + m.oauthConfig = oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Endpoint: provider.Endpoint(), + RedirectURL: cfg.RedirectURI, + Scopes: append([]string(nil), cfg.Scopes...), + } + m.accessVerifier = provider.Verifier(&oidc.Config{ + ClientID: cfg.ResourceAudience, + SupportedSigningAlgs: append([]string(nil), cfg.AllowedSigningAlgorithms...), + }) + m.idVerifier = provider.Verifier(&oidc.Config{ + ClientID: cfg.ClientID, + SupportedSigningAlgs: append([]string(nil), cfg.AllowedSigningAlgorithms...), + }) + var metadata struct { + EndSessionEndpoint string `json:"end_session_endpoint"` + } + if err := provider.Claims(&metadata); err == nil { + m.endSessionEndpoint = strings.TrimSpace(metadata.EndSessionEndpoint) + } + return m, nil +} + +func validateConfiguredScopes(configured []string) error { + return validateConfiguredScopesWithPrefix(configured, ScopePrefix, AllPortalScopes) +} + +func validateConfiguredScopesWithPrefix(configured []string, prefix string, portalScopes []string) error { + canonical := make(map[string]struct{}, len(portalScopes)) + for _, scope := range portalScopes { + canonical[scope] = struct{}{} + } + for _, scope := range configured { + // An empty prefix explicitly means that portal scopes have no namespace. + // In that mode, exact route-policy constants remain authoritative, but + // arbitrary OIDC scopes cannot be classified as portal-owned by prefix. + if prefix != "" && strings.HasPrefix(scope, prefix) { + if _, ok := canonical[scope]; !ok { + return errors.New("auth scopes contain an unknown portal scope") + } + } + } + return nil +} + +// RegisterRoutes registers browser-facing authentication endpoints. +func (m *Manager) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /auth/login", m.Login) + mux.HandleFunc("GET /auth/callback", m.Callback) + mux.HandleFunc("POST /auth/refresh", m.Refresh) + mux.Handle("POST /auth/logout", m.Require(http.HandlerFunc(m.Logout))) +} + +// Require authenticates a request and optionally enforces scopes. +func (m *Manager) Require(next http.Handler, requiredScopes ...string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + principal, err := m.authenticate(r) + if err != nil { + w.Header().Set("WWW-Authenticate", "Bearer") + writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "authentication required") + return + } + for _, required := range requiredScopes { + if _, ok := principal.Scopes[required]; !ok { + writeError(w, http.StatusForbidden, "FORBIDDEN", "insufficient permissions") + return + } + } + ctx := systemcontext.WithPrincipal(r.Context(), principal) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// RequireAPI authenticates an /api request and enforces its canonical route scope. +func (m *Manager) RequireAPI(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + required, ok := ScopeForAPIRequest(r.Method, r.URL.Path) + if !ok { + if isKnownAPIPath(r.URL.Path) { + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed") + return + } + writeError(w, http.StatusNotFound, "NOT_FOUND", "route not found") + return + } + m.Require(next, required).ServeHTTP(w, r) + }) +} + +func (m *Manager) authenticate(r *http.Request) (systemcontext.Principal, error) { + if !m.cfg.Enabled { + if !m.proxyCfg.PlaceholderModeEnabled { + return systemcontext.Principal{}, errInvalidCredentials + } + userID := strings.TrimSpace(m.proxyCfg.PlaceholderUserID) + orgID := strings.TrimSpace(m.proxyCfg.PlaceholderOrgID) + if userID == "" || orgID == "" { + return systemcontext.Principal{}, errInvalidCredentials + } + scopes := make(map[string]struct{}, len(AllPortalScopes)) + for _, scope := range AllPortalScopes { + scopes[scope] = struct{}{} + } + return systemcontext.Principal{UserID: userID, OrgID: orgID, Scopes: scopes}, nil + } + part1, err := bearerPart(r) + if err != nil { + return systemcontext.Principal{}, err + } + part2, err := exactlyOneCookie(r, m.cfg.AccessTokenPart2Cookie) + if err != nil { + return systemcontext.Principal{}, err + } + token, err := reconstructToken(part1, part2, m.cfg) + if err != nil { + return systemcontext.Principal{}, err + } + principal, _, err := m.validateAccessToken(r.Context(), token) + return principal, err +} + +func (m *Manager) validateAccessToken(ctx context.Context, raw string) (systemcontext.Principal, time.Time, error) { + ctx = oidc.ClientContext(ctx, m.httpClient) + verified, err := m.accessVerifier.Verify(ctx, raw) + if err != nil { + return systemcontext.Principal{}, time.Time{}, errInvalidCredentials + } + claims := map[string]json.RawMessage{} + if err := verified.Claims(&claims); err != nil { + return systemcontext.Principal{}, time.Time{}, errInvalidCredentials + } + subject := strings.TrimSpace(verified.Subject) + orgID, err := stringClaim(claims, m.cfg.OrgIDClaim) + if err != nil || subject == "" || orgID == "" { + return systemcontext.Principal{}, time.Time{}, errInvalidCredentials + } + if err := validateNotBefore(claims, m.cfg.ClockSkew); err != nil { + return systemcontext.Principal{}, time.Time{}, errInvalidCredentials + } + if m.cfg.RequireAccessTokenType { + value, claimErr := stringClaim(claims, m.cfg.AccessTokenTypeClaim) + if claimErr != nil || value != m.cfg.AccessTokenTypeValue { + return systemcontext.Principal{}, time.Time{}, errInvalidCredentials + } + } + scopes, err := scopeClaim(claims, m.cfg.ScopeClaim) + if err != nil { + return systemcontext.Principal{}, time.Time{}, errInvalidCredentials + } + return systemcontext.Principal{UserID: subject, OrgID: orgID, Scopes: scopes}, verified.Expiry, nil +} + +func (m *Manager) validateIDToken(ctx context.Context, raw string) (time.Time, error) { + ctx = oidc.ClientContext(ctx, m.httpClient) + verified, err := m.idVerifier.Verify(ctx, raw) + if err != nil || strings.TrimSpace(verified.Subject) == "" { + return time.Time{}, errInvalidCredentials + } + claims := map[string]json.RawMessage{} + if err := verified.Claims(&claims); err != nil || validateNotBefore(claims, m.cfg.ClockSkew) != nil { + return time.Time{}, errInvalidCredentials + } + return verified.Expiry, nil +} + +func stringClaim(claims map[string]json.RawMessage, name string) (string, error) { + raw, ok := claims[name] + if !ok { + return "", errInvalidCredentials + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", errInvalidCredentials + } + return strings.TrimSpace(value), nil +} + +func scopeClaim(claims map[string]json.RawMessage, name string) (map[string]struct{}, error) { + raw, ok := claims[name] + if !ok { + return map[string]struct{}{}, nil + } + var text string + values := []string{} + if err := json.Unmarshal(raw, &text); err == nil { + values = strings.Fields(text) + } else if err := json.Unmarshal(raw, &values); err != nil { + return nil, errInvalidCredentials + } + result := make(map[string]struct{}, len(values)) + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + result[value] = struct{}{} + } + } + return result, nil +} + +func validateNotBefore(claims map[string]json.RawMessage, skew time.Duration) error { + raw, ok := claims["nbf"] + if !ok { + return nil + } + var seconds json.Number + if err := json.Unmarshal(raw, &seconds); err != nil { + return errInvalidCredentials + } + value, err := seconds.Int64() + if err != nil || time.Unix(value, 0).After(time.Now().Add(skew)) { + return errInvalidCredentials + } + return nil +} diff --git a/portal/backend/internal/system/auth/manager_test.go b/portal/backend/internal/system/auth/manager_test.go new file mode 100644 index 00000000..149a4bf4 --- /dev/null +++ b/portal/backend/internal/system/auth/manager_test.go @@ -0,0 +1,70 @@ +/* + * 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" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/wso2/openfgc/portal/backend/internal/system/config" + systemcontext "github.com/wso2/openfgc/portal/backend/internal/system/context" +) + +func TestPlaceholderModePopulatesPrincipalContext(t *testing.T) { + manager, err := NewManager( + context.Background(), + config.AuthConfig{}, + config.ProxyConfig{ + PlaceholderModeEnabled: true, + PlaceholderUserID: " user-1 ", + PlaceholderOrgID: " org-1 ", + }, + slog.New(slog.NewTextHandler(io.Discard, nil)), + ) + if err != nil { + t.Fatalf("new manager: %v", err) + } + + request := httptest.NewRequest(http.MethodGet, "/protected", nil) + recorder := httptest.NewRecorder() + manager.Require(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + principal, ok := systemcontext.PrincipalFromContext(r.Context()) + if !ok { + t.Error("expected placeholder principal in request context") + return + } + if principal.UserID != "user-1" || principal.OrgID != "org-1" { + t.Errorf("unexpected placeholder principal: %#v", principal) + } + for _, scope := range AllPortalScopes { + if _, ok := principal.Scopes[scope]; !ok { + t.Errorf("placeholder principal missing scope %q", scope) + } + } + w.WriteHeader(http.StatusNoContent) + })).ServeHTTP(recorder, request) + + if recorder.Code != http.StatusNoContent { + t.Fatalf("unexpected response status: %d", recorder.Code) + } +} diff --git a/portal/backend/internal/system/auth/scopes.go b/portal/backend/internal/system/auth/scopes.go new file mode 100644 index 00000000..50fb6a57 --- /dev/null +++ b/portal/backend/internal/system/auth/scopes.go @@ -0,0 +1,119 @@ +/* + * 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 ( + "net/http" + "strings" +) + +// ScopePrefix and the remaining constants define the canonical portal authorization scopes. +const ( + // ScopePrefix is the namespace shared by all portal-owned scopes. + // Set it to an empty string when the identity provider uses unprefixed scopes. + ScopePrefix = "portal:" + + ScopeConsentsReadSelf = ScopePrefix + "consents:read:self" + ScopeConsentsWriteSelf = ScopePrefix + "consents:write:self" + ScopeConsentsReadAny = ScopePrefix + "consents:read:any" + ScopeConsentsWriteAny = ScopePrefix + "consents:write:any" + ScopeElementsRead = ScopePrefix + "elements:read" + ScopeElementsWrite = ScopePrefix + "elements:write" + ScopePurposesRead = ScopePrefix + "purposes:read" + ScopePurposesWrite = ScopePrefix + "purposes:write" +) + +// AllPortalScopes lists every canonical portal authorization scope. +var AllPortalScopes = []string{ + ScopeConsentsReadSelf, ScopeConsentsWriteSelf, + ScopeConsentsReadAny, ScopeConsentsWriteAny, + ScopeElementsRead, ScopeElementsWrite, + ScopePurposesRead, ScopePurposesWrite, +} + +type apiScopePolicy struct { + method, pattern, scope string +} + +// apiScopePolicies explicitly allowlists operations exposed through the +// catch-all API proxy. New upstream operations require a deliberate policy. +var apiScopePolicies = []apiScopePolicy{ + {http.MethodGet, "/api/consents", ScopeConsentsReadAny}, + {http.MethodPost, "/api/consents", ScopeConsentsWriteAny}, + {http.MethodGet, "/api/consents/attributes", ScopeConsentsReadAny}, + {http.MethodPost, "/api/consents/validate", ScopeConsentsReadAny}, + {http.MethodGet, "/api/consents/{consentId}", ScopeConsentsReadAny}, + {http.MethodPut, "/api/consents/{consentId}", ScopeConsentsWriteAny}, + {http.MethodGet, "/api/consents/{consentId}/history", ScopeConsentsReadAny}, + {http.MethodPost, "/api/consents/{consentId}/revoke", ScopeConsentsWriteAny}, + {http.MethodGet, "/api/consents/{consentId}/authorizations", ScopeConsentsReadAny}, + {http.MethodPost, "/api/consents/{consentId}/authorizations", ScopeConsentsWriteAny}, + {http.MethodGet, "/api/consents/{consentId}/authorizations/{authorizationId}", ScopeConsentsReadAny}, + {http.MethodPut, "/api/consents/{consentId}/authorizations/{authorizationId}", ScopeConsentsWriteAny}, + {http.MethodGet, "/api/consent-elements", ScopeElementsRead}, + {http.MethodPost, "/api/consent-elements", ScopeElementsWrite}, + {http.MethodGet, "/api/consent-elements/{elementId}", ScopeElementsRead}, + {http.MethodGet, "/api/consent-elements/{elementId}/versions", ScopeElementsRead}, + {http.MethodPost, "/api/consent-elements/{elementId}/versions", ScopeElementsWrite}, + {http.MethodGet, "/api/consent-elements/{elementId}/versions/{version}", ScopeElementsRead}, + {http.MethodDelete, "/api/consent-elements/{elementId}/versions/{version}", ScopeElementsWrite}, + {http.MethodGet, "/api/consent-purposes", ScopePurposesRead}, + {http.MethodPost, "/api/consent-purposes", ScopePurposesWrite}, + {http.MethodGet, "/api/consent-purposes/{purposeId}", ScopePurposesRead}, + {http.MethodGet, "/api/consent-purposes/{purposeId}/versions", ScopePurposesRead}, + {http.MethodPost, "/api/consent-purposes/{purposeId}/versions", ScopePurposesWrite}, + {http.MethodGet, "/api/consent-purposes/{purposeId}/versions/{version}", ScopePurposesRead}, + {http.MethodDelete, "/api/consent-purposes/{purposeId}/versions/{version}", ScopePurposesWrite}, +} + +// ScopeForAPIRequest returns the canonical scope for an explicitly allowlisted +// API operation. +func ScopeForAPIRequest(method, path string) (string, bool) { + method = strings.ToUpper(method) + for _, policy := range apiScopePolicies { + if method == policy.method && matchAPIPath(policy.pattern, path) { + return policy.scope, true + } + } + return "", false +} + +func isKnownAPIPath(path string) bool { + for _, policy := range apiScopePolicies { + if matchAPIPath(policy.pattern, path) { + return true + } + } + return false +} + +func matchAPIPath(pattern, path string) bool { + patternParts := strings.Split(strings.Trim(pattern, "/"), "/") + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(patternParts) != len(pathParts) { + return false + } + for i, patternPart := range patternParts { + placeholder := strings.HasPrefix(patternPart, "{") && strings.HasSuffix(patternPart, "}") + if (!placeholder && patternPart != pathParts[i]) || pathParts[i] == "" { + return false + } + } + return true +} diff --git a/portal/backend/internal/system/auth/scopes_test.go b/portal/backend/internal/system/auth/scopes_test.go new file mode 100644 index 00000000..6e620511 --- /dev/null +++ b/portal/backend/internal/system/auth/scopes_test.go @@ -0,0 +1,252 @@ +/* + * 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 ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "go.yaml.in/yaml/v3" +) + +func TestValidateConfiguredScopesSupportsEmptyPrefix(t *testing.T) { + if err := validateConfiguredScopesWithPrefix( + []string{"openid", "consents:read:self"}, + "", + []string{"consents:read:self"}, + ); err != nil { + t.Fatalf("empty scope prefix should be supported: %v", err) + } +} + +func TestValidateConfiguredScopesRejectsUnknownPrefixedScope(t *testing.T) { + err := validateConfiguredScopesWithPrefix( + []string{"openid", "portal:unknown"}, + "portal:", + []string{"portal:consents:read:self"}, + ) + if err == nil { + t.Fatal("expected an unknown prefixed scope to be rejected") + } +} + +func TestCanonicalScopesAreUniqueAndPrefixed(t *testing.T) { + seen := make(map[string]struct{}, len(AllPortalScopes)) + for _, scope := range AllPortalScopes { + if strings.TrimSpace(scope) == "" { + t.Fatal("canonical scope must not be empty") + } + if ScopePrefix != "" && !strings.HasPrefix(scope, ScopePrefix) { + t.Errorf("scope %q does not use prefix %q", scope, ScopePrefix) + } + if _, duplicate := seen[scope]; duplicate { + t.Errorf("duplicate canonical scope %q", scope) + } + seen[scope] = struct{}{} + } + if len(seen) != 8 { + t.Fatalf("expected eight canonical scopes, got %d", len(seen)) + } +} + +func TestEveryAPIRouteHasCanonicalScopePolicy(t *testing.T) { + tests := []struct { + method string + path string + want string + }{ + {"GET", "/api/consents", ScopeConsentsReadAny}, + {"POST", "/api/consents", ScopeConsentsWriteAny}, + {"GET", "/api/consents/attributes", ScopeConsentsReadAny}, + {"POST", "/api/consents/validate", ScopeConsentsReadAny}, + {"GET", "/api/consents/c1", ScopeConsentsReadAny}, + {"PUT", "/api/consents/c1", ScopeConsentsWriteAny}, + {"GET", "/api/consents/c1/history", ScopeConsentsReadAny}, + {"POST", "/api/consents/c1/revoke", ScopeConsentsWriteAny}, + {"GET", "/api/consents/c1/authorizations", ScopeConsentsReadAny}, + {"POST", "/api/consents/c1/authorizations", ScopeConsentsWriteAny}, + {"GET", "/api/consents/c1/authorizations/a1", ScopeConsentsReadAny}, + {"PUT", "/api/consents/c1/authorizations/a1", ScopeConsentsWriteAny}, + {"GET", "/api/consent-elements", ScopeElementsRead}, + {"POST", "/api/consent-elements", ScopeElementsWrite}, + {"GET", "/api/consent-elements/e1", ScopeElementsRead}, + {"GET", "/api/consent-elements/e1/versions", ScopeElementsRead}, + {"POST", "/api/consent-elements/e1/versions", ScopeElementsWrite}, + {"GET", "/api/consent-elements/e1/versions/v1", ScopeElementsRead}, + {"DELETE", "/api/consent-elements/e1/versions/v1", ScopeElementsWrite}, + {"GET", "/api/consent-purposes", ScopePurposesRead}, + {"POST", "/api/consent-purposes", ScopePurposesWrite}, + {"GET", "/api/consent-purposes/p1", ScopePurposesRead}, + {"GET", "/api/consent-purposes/p1/versions", ScopePurposesRead}, + {"POST", "/api/consent-purposes/p1/versions", ScopePurposesWrite}, + {"GET", "/api/consent-purposes/p1/versions/v1", ScopePurposesRead}, + {"DELETE", "/api/consent-purposes/p1/versions/v1", ScopePurposesWrite}, + } + known := make(map[string]struct{}, len(AllPortalScopes)) + for _, scope := range AllPortalScopes { + known[scope] = struct{}{} + } + for _, test := range tests { + got, ok := ScopeForAPIRequest(test.method, test.path) + if !ok || got != test.want { + t.Errorf("%s %s: got %q, %v; want %q", test.method, test.path, got, ok, test.want) + } + if _, ok := known[got]; !ok { + t.Errorf("%s %s references unregistered scope %q", test.method, test.path, got) + } + } + for _, test := range []struct{ method, path string }{ + {"PATCH", "/api/consents/c1"}, + {"GET", "/api/unknown"}, + {"GET", "/not-api/consents"}, + {"POST", "/api/consents/c1/export"}, + {"DELETE", "/api/consent-elements/e1"}, + } { + if scope, ok := ScopeForAPIRequest(test.method, test.path); ok { + t.Errorf("unexpected policy for %s %s: %q", test.method, test.path, scope) + } + } +} + +func TestKnownAPIPathIgnoresMethod(t *testing.T) { + if !isKnownAPIPath("/api/consents/c1/revoke") { + t.Fatal("documented API path should be known") + } + if isKnownAPIPath("/api/consents/c1/export") { + t.Fatal("undocumented API path should not be known") + } +} + +func TestOpenAPIScopesMatchCanonicalRoutePolicies(t *testing.T) { + path := filepath.Join("..", "..", "..", "openapi", "portal-backend.yaml") + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read OpenAPI document: %v", err) + } + var document map[string]any + if err := yaml.Unmarshal(content, &document); err != nil { + t.Fatalf("parse OpenAPI document: %v", err) + } + + known := make(map[string]struct{}, len(AllPortalScopes)) + for _, scope := range AllPortalScopes { + known[scope] = struct{}{} + } + components := mustMap(t, document["components"], "components") + schemes := mustMap(t, components["securitySchemes"], "securitySchemes") + oauth := mustMap(t, schemes["PortalOAuthDocumentation"], "PortalOAuthDocumentation") + flows := mustMap(t, oauth["flows"], "flows") + authorizationCode := mustMap(t, flows["authorizationCode"], "authorizationCode") + documentedScopes := mustMap(t, authorizationCode["scopes"], "scopes") + if len(documentedScopes) != len(known) { + t.Fatalf("OpenAPI documents %d scopes; registry has %d", len(documentedScopes), len(known)) + } + for scope := range documentedScopes { + if _, ok := known[scope]; !ok { + t.Errorf("OpenAPI documents unknown scope %q", scope) + } + } + + paths := mustMap(t, document["paths"], "paths") + placeholder := regexp.MustCompile(`\{[^}]+\}`) + for route, rawPathItem := range paths { + pathItem := mustMap(t, rawPathItem, route) + for method, rawOperation := range pathItem { + upperMethod := strings.ToUpper(method) + if !isHTTPMethod(upperMethod) { + continue + } + operation := mustMap(t, rawOperation, upperMethod+" "+route) + scopes := operationOAuthScopes(t, operation) + for _, scope := range scopes { + if _, ok := known[scope]; !ok { + t.Errorf("%s %s uses unknown scope %q", upperMethod, route, scope) + } + } + samplePath := placeholder.ReplaceAllString(route, "sample") + var want string + switch { + case strings.HasPrefix(route, "/api/"): + policy, ok := ScopeForAPIRequest(upperMethod, samplePath) + if !ok { + t.Errorf("OpenAPI operation lacks code policy: %s %s", upperMethod, route) + continue + } + want = policy + case strings.HasPrefix(route, "/me/"): + if upperMethod == "GET" { + want = ScopeConsentsReadSelf + } else { + want = ScopeConsentsWriteSelf + } + default: + continue + } + if len(scopes) != 1 || scopes[0] != want { + t.Errorf("%s %s documents %v; code requires %q", upperMethod, route, scopes, want) + } + } + } +} + +func operationOAuthScopes(t *testing.T, operation map[string]any) []string { + t.Helper() + rawSecurity, ok := operation["security"].([]any) + if !ok { + return nil + } + for _, rawRequirement := range rawSecurity { + requirement := mustMap(t, rawRequirement, "security requirement") + rawScopes, ok := requirement["PortalOAuthDocumentation"].([]any) + if !ok { + continue + } + result := make([]string, 0, len(rawScopes)) + for _, rawScope := range rawScopes { + scope, ok := rawScope.(string) + if !ok { + t.Fatalf("non-string OAuth scope: %#v", rawScope) + } + result = append(result, scope) + } + return result + } + return nil +} + +func mustMap(t *testing.T, value any, name string) map[string]any { + t.Helper() + result, ok := value.(map[string]any) + if !ok { + t.Fatalf("%s is not an object: %#v", name, value) + } + return result +} + +func isHTTPMethod(value string) bool { + switch value { + case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE": + return true + default: + return false + } +} diff --git a/portal/backend/internal/system/auth/tokens.go b/portal/backend/internal/system/auth/tokens.go new file mode 100644 index 00000000..f5f47e07 --- /dev/null +++ b/portal/backend/internal/system/auth/tokens.go @@ -0,0 +1,133 @@ +/* + * 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 ( + "net/http" + "strings" + "time" + + "github.com/wso2/openfgc/portal/backend/internal/system/config" +) + +func splitToken(token string, maxPartBytes int) (string, string, error) { + if token == "" { + return "", "", errInvalidCredentials + } + midpoint := len(token) / 2 + part1, part2 := token[:midpoint], token[midpoint:] + if part1 == "" || part2 == "" || len(part1) > maxPartBytes || len(part2) > maxPartBytes { + return "", "", errInvalidCredentials + } + return part1, part2, nil +} + +func reconstructToken(part1, part2 string, cfg config.AuthConfig) (string, error) { + if part1 == "" || part2 == "" || len(part1) > cfg.MaxTokenPartBytes || len(part2) > cfg.MaxTokenPartBytes { + return "", errInvalidCredentials + } + if len(part1)+len(part2) > cfg.MaxReconstructedTokenBytes { + return "", errInvalidCredentials + } + return part1 + part2, nil +} + +func bearerPart(r *http.Request) (string, error) { + values := r.Header.Values("Authorization") + if len(values) != 1 || strings.Contains(values[0], ",") { + return "", errInvalidCredentials + } + fields := strings.Fields(values[0]) + if len(fields) != 2 || !strings.EqualFold(fields[0], "Bearer") || fields[1] == "" { + return "", errInvalidCredentials + } + return fields[1], nil +} + +func exactlyOneCookie(r *http.Request, name string) (string, error) { + var values []string + for _, cookie := range r.Cookies() { + if cookie.Name == name { + values = append(values, cookie.Value) + } + } + if len(values) != 1 || values[0] == "" { + return "", errInvalidCredentials + } + return values[0], nil +} + +func (m *Manager) setSplitCookies(w http.ResponseWriter, token, part1Name, part2Name string, part2HTTPOnly bool, expiry time.Time) error { + part1, part2, err := splitToken(token, m.cfg.MaxTokenPartBytes) + if err != nil { + return err + } + m.setCookie(w, part1Name, part1, false, expiry) + m.setCookie(w, part2Name, part2, part2HTTPOnly, expiry) + return nil +} + +func (m *Manager) setCookie(w http.ResponseWriter, name, value string, httpOnly bool, expiry time.Time) { + maxAge := int(time.Until(expiry).Seconds()) + if maxAge < 1 { + maxAge = 1 + } + http.SetCookie(w, &http.Cookie{ + Name: name, Value: value, Path: "/", Expires: expiry, MaxAge: maxAge, + Secure: m.cfg.CookieSecure, HttpOnly: httpOnly, SameSite: parseSameSite(m.cfg.CookieSameSite), + }) +} + +func (m *Manager) clearTokenCookies(w http.ResponseWriter) { + names := []string{ + m.cfg.AccessTokenPart1Cookie, m.cfg.AccessTokenPart2Cookie, + m.cfg.RefreshTokenPart1Cookie, m.cfg.RefreshTokenPart2Cookie, + m.cfg.IDTokenPart1Cookie, m.cfg.IDTokenPart2Cookie, + } + for _, name := range names { + http.SetCookie(w, &http.Cookie{ + Name: name, Value: "", Path: "/", MaxAge: -1, Expires: time.Unix(1, 0), + Secure: m.cfg.CookieSecure, HttpOnly: name == m.cfg.AccessTokenPart2Cookie || name == m.cfg.RefreshTokenPart2Cookie, + SameSite: parseSameSite(m.cfg.CookieSameSite), + }) + } +} + +func parseSameSite(value string) http.SameSite { + switch strings.ToLower(strings.TrimSpace(value)) { + case "strict": + return http.SameSiteStrictMode + case "none": + return http.SameSiteNoneMode + default: + return http.SameSiteLaxMode + } +} + +func joinCookieToken(r *http.Request, part1Name, part2Name string, cfg config.AuthConfig) (string, error) { + part1, err := exactlyOneCookie(r, part1Name) + if err != nil { + return "", err + } + part2, err := exactlyOneCookie(r, part2Name) + if err != nil { + return "", err + } + return reconstructToken(part1, part2, cfg) +} diff --git a/portal/backend/internal/system/auth/tokens_test.go b/portal/backend/internal/system/auth/tokens_test.go new file mode 100644 index 00000000..bb42824e --- /dev/null +++ b/portal/backend/internal/system/auth/tokens_test.go @@ -0,0 +1,197 @@ +/* + * 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 ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/wso2/openfgc/portal/backend/internal/system/config" +) + +func TestSplitTokenEdgeCases(t *testing.T) { + tests := []struct { + name string + token string + maxPart int + want1 string + want2 string + wantErr bool + }{ + {name: "even", token: "abcdef", maxPart: 3, want1: "abc", want2: "def"}, + {name: "odd midpoint", token: "abcde", maxPart: 3, want1: "ab", want2: "cde"}, + {name: "empty", token: "", maxPart: 3, wantErr: true}, + {name: "one byte", token: "a", maxPart: 1, wantErr: true}, + {name: "part too large", token: "abcdefg", maxPart: 3, wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + part1, part2, err := splitToken(test.token, test.maxPart) + if (err != nil) != test.wantErr { + t.Fatalf("unexpected error: %v", err) + } + if part1 != test.want1 || part2 != test.want2 { + t.Fatalf("unexpected parts: %q %q", part1, part2) + } + }) + } +} + +func TestReconstructTokenLimitsAndOrder(t *testing.T) { + cfg := config.AuthConfig{MaxTokenPartBytes: 4, MaxReconstructedTokenBytes: 7} + if got, err := reconstructToken("abc", "defg", cfg); err != nil || got != "abcdefg" { + t.Fatalf("unexpected reconstruction: %q, %v", got, err) + } + if got, err := reconstructToken("defg", "abc", cfg); err != nil || got != "defgabc" { + t.Fatalf("reconstruction must preserve supplied order: %q, %v", got, err) + } + for _, parts := range [][2]string{{"", "a"}, {"a", ""}, {"abcde", "a"}, {"abcd", "abcd"}} { + if _, err := reconstructToken(parts[0], parts[1], cfg); err == nil { + t.Fatalf("expected reconstruction to reject %#v", parts) + } + } +} + +func TestBearerPartStrictParsing(t *testing.T) { + tests := []struct { + name string + values []string + want string + wantErr bool + }{ + {name: "valid", values: []string{"Bearer abc"}, want: "abc"}, + {name: "case insensitive scheme", values: []string{"bearer abc"}, want: "abc"}, + {name: "missing", wantErr: true}, + {name: "empty", values: []string{""}, wantErr: true}, + {name: "scheme only", values: []string{"Bearer"}, wantErr: true}, + {name: "wrong scheme", values: []string{"Basic abc"}, wantErr: true}, + {name: "extra field", values: []string{"Bearer abc def"}, wantErr: true}, + {name: "combined values", values: []string{"Bearer abc, Bearer def"}, wantErr: true}, + {name: "duplicate headers", values: []string{"Bearer abc", "Bearer def"}, wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + for _, value := range test.values { + r.Header.Add("Authorization", value) + } + got, err := bearerPart(r) + if (err != nil) != test.wantErr || got != test.want { + t.Fatalf("got %q, %v", got, err) + } + }) + } +} + +func TestExactlyOneCookie(t *testing.T) { + tests := []struct { + name string + header string + want string + wantErr bool + }{ + {name: "valid", header: "other=x; token=part2", want: "part2"}, + {name: "missing", header: "other=x", wantErr: true}, + {name: "empty", header: "token=", wantErr: true}, + {name: "duplicate", header: "token=a; token=b", wantErr: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Cookie", test.header) + got, err := exactlyOneCookie(r, "token") + if (err != nil) != test.wantErr || got != test.want { + t.Fatalf("got %q, %v", got, err) + } + }) + } +} + +func TestCookieIssuanceAndExactClearing(t *testing.T) { + cfg := config.AuthConfig{ + CookieSecure: true, CookieSameSite: "Strict", MaxTokenPartBytes: 32, + AccessTokenPart1Cookie: "at1", AccessTokenPart2Cookie: "at2", + RefreshTokenPart1Cookie: "rt1", RefreshTokenPart2Cookie: "rt2", + IDTokenPart1Cookie: "id1", IDTokenPart2Cookie: "id2", + } + manager := &Manager{cfg: cfg} + + issued := httptest.NewRecorder() + if err := manager.setSplitCookies(issued, "abcdefgh", "at1", "at2", true, time.Now().Add(time.Hour)); err != nil { + t.Fatal(err) + } + cookies := issued.Result().Cookies() + if len(cookies) != 2 { + t.Fatalf("expected two cookies, got %d", len(cookies)) + } + for index, cookie := range cookies { + if cookie.Path != "/" || cookie.Domain != "" || !cookie.Secure || cookie.SameSite != http.SameSiteStrictMode || cookie.MaxAge < 1 { + t.Fatalf("unexpected issued cookie: %#v", cookie) + } + if cookie.HttpOnly != (index == 1) { + t.Fatalf("unexpected HttpOnly value for %s", cookie.Name) + } + } + + cleared := httptest.NewRecorder() + manager.clearTokenCookies(cleared) + clearCookies := cleared.Result().Cookies() + if len(clearCookies) != 6 { + t.Fatalf("expected six clearing cookies, got %d", len(clearCookies)) + } + for _, cookie := range clearCookies { + if cookie.Value != "" || cookie.Path != "/" || cookie.Domain != "" || cookie.MaxAge != -1 || !cookie.Secure || cookie.SameSite != http.SameSiteStrictMode { + t.Fatalf("unexpected clearing cookie: %#v", cookie) + } + wantHTTPOnly := cookie.Name == "at2" || cookie.Name == "rt2" + if cookie.HttpOnly != wantHTTPOnly { + t.Fatalf("unexpected clearing HttpOnly value for %s", cookie.Name) + } + } +} + +func TestParseSameSite(t *testing.T) { + tests := map[string]http.SameSite{ + "Lax": http.SameSiteLaxMode, "unknown": http.SameSiteLaxMode, + "Strict": http.SameSiteStrictMode, "None": http.SameSiteNoneMode, + } + for input, want := range tests { + if got := parseSameSite(input); got != want { + t.Errorf("%q: got %v, want %v", input, got, want) + } + } +} + +func TestCompleteTokenInjectionCannotReplaceMissingPart(t *testing.T) { + manager := &Manager{cfg: config.AuthConfig{ + Enabled: true, AccessTokenPart2Cookie: "at2", MaxTokenPartBytes: 100, + MaxReconstructedTokenBytes: 200, + }} + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "Bearer "+strings.Repeat("x", 20)) + if _, err := manager.authenticate(r); err == nil { + t.Fatal("a complete bearer token without the matching cookie half must be rejected") + } +} diff --git a/portal/backend/internal/system/auth/transactions.go b/portal/backend/internal/system/auth/transactions.go new file mode 100644 index 00000000..c62cfad9 --- /dev/null +++ b/portal/backend/internal/system/auth/transactions.go @@ -0,0 +1,107 @@ +/* + * 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 ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "net/http" + "time" + + "golang.org/x/oauth2" +) + +const loginTransactionRandomBytes = 32 + +func newLoginTransaction() (state string, verifier string, err error) { + state, err = randomBase64URL(loginTransactionRandomBytes) + if err != nil { + return "", "", err + } + verifier, err = randomBase64URL(loginTransactionRandomBytes) + if err != nil { + return "", "", err + } + return state, verifier, nil +} + +func randomBase64URL(size int) (string, error) { + value := make([]byte, size) + if _, err := rand.Read(value); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +func pkceChallenge(verifier string) string { + digest := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(digest[:]) +} + +func validPKCEVerifier(verifier string) bool { + if len(verifier) < 43 || len(verifier) > 128 { + return false + } + for _, character := range verifier { + if (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || character == '-' || character == '.' || + character == '_' || character == '~' { + continue + } + return false + } + return true +} + +func statesMatch(expected, actual string) bool { + if expected == "" || actual == "" || len(expected) != len(actual) { + return false + } + return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1 +} + +func (m *Manager) setLoginTransactionCookies(w http.ResponseWriter, state, verifier string) { + expiry := time.Now().Add(time.Duration(m.cfg.LoginTransactionMaxAgeSeconds) * time.Second) + for name, value := range map[string]string{ + m.cfg.OAuthStateCookie: state, + m.cfg.PKCEVerifierCookie: verifier, + } { + http.SetCookie(w, &http.Cookie{ + Name: name, Value: value, Path: "/", Expires: expiry, + MaxAge: m.cfg.LoginTransactionMaxAgeSeconds, Secure: m.cfg.CookieSecure, + HttpOnly: true, SameSite: parseSameSite(m.cfg.CookieSameSite), + }) + } +} + +func (m *Manager) clearLoginTransactionCookies(w http.ResponseWriter) { + for _, name := range []string{m.cfg.OAuthStateCookie, m.cfg.PKCEVerifierCookie} { + http.SetCookie(w, &http.Cookie{ + Name: name, Value: "", Path: "/", MaxAge: -1, Expires: time.Unix(1, 0), + Secure: m.cfg.CookieSecure, HttpOnly: true, + SameSite: parseSameSite(m.cfg.CookieSameSite), + }) + } +} + +func pkceAuthorizationOption(verifier string) oauth2.AuthCodeOption { + return oauth2.S256ChallengeOption(verifier) +} diff --git a/portal/backend/internal/system/auth/transactions_test.go b/portal/backend/internal/system/auth/transactions_test.go new file mode 100644 index 00000000..e7f6aea0 --- /dev/null +++ b/portal/backend/internal/system/auth/transactions_test.go @@ -0,0 +1,52 @@ +/* + * 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 "testing" + +func TestLoginTransactionGenerationAndPKCEChallenge(t *testing.T) { + state, verifier, err := newLoginTransaction() + if err != nil { + t.Fatal(err) + } + if len(state) != 43 || !validPKCEVerifier(verifier) || state == verifier { + t.Fatalf("unexpected generated transaction values") + } + + const rfcVerifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + const rfcChallenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + if got := pkceChallenge(rfcVerifier); got != rfcChallenge { + t.Fatalf("PKCE challenge = %q, want %q", got, rfcChallenge) + } +} + +func TestPKCEVerifierAndStateValidation(t *testing.T) { + valid := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~" + if !validPKCEVerifier(valid) { + t.Fatal("valid RFC 7636 verifier rejected") + } + for _, verifier := range []string{"short", valid + "!", string(make([]byte, 129))} { + if validPKCEVerifier(verifier) { + t.Fatalf("invalid verifier accepted: %q", verifier) + } + } + if !statesMatch("matching-state", "matching-state") || statesMatch("matching-state", "different-state") || statesMatch("", "") { + t.Fatal("unexpected state comparison result") + } +} diff --git a/portal/backend/internal/system/auth/validation_test.go b/portal/backend/internal/system/auth/validation_test.go new file mode 100644 index 00000000..ce5ef2c2 --- /dev/null +++ b/portal/backend/internal/system/auth/validation_test.go @@ -0,0 +1,499 @@ +/* + * 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" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + + "github.com/wso2/openfgc/portal/backend/internal/system/config" + systemcontext "github.com/wso2/openfgc/portal/backend/internal/system/context" +) + +type oidcValidationProvider struct { + t *testing.T + server *httptest.Server + key1 *rsa.PrivateKey + key2 *rsa.PrivateKey + key3 *rsa.PrivateKey + + mu sync.Mutex + keys []jose.JSONWebKey + jwksCalls int + failNextJW bool + token http.HandlerFunc + endSession bool +} + +func newOIDCValidationProvider(t *testing.T) *oidcValidationProvider { + t.Helper() + p := &oidcValidationProvider{t: t} + p.key1 = generateRSAKey(t) + p.key2 = generateRSAKey(t) + p.key3 = generateRSAKey(t) + p.keys = []jose.JSONWebKey{publicJWK(p.key1, "key-1")} + p.server = httptest.NewServer(http.HandlerFunc(p.serveHTTP)) + t.Cleanup(p.server.Close) + return p +} + +func (p *oidcValidationProvider) serveHTTP(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + metadata := map[string]any{ + "issuer": p.server.URL, "authorization_endpoint": p.server.URL + "/authorize", + "token_endpoint": p.server.URL + "/token", "jwks_uri": p.server.URL + "/jwks", + "id_token_signing_alg_values_supported": []string{"RS256"}, + } + if p.endSession { + metadata["end_session_endpoint"] = p.server.URL + "/logout" + } + writeTestJSON(w, metadata) + case "/jwks": + p.mu.Lock() + p.jwksCalls++ + fail := p.failNextJW + p.failNextJW = false + keys := append([]jose.JSONWebKey(nil), p.keys...) + p.mu.Unlock() + if fail { + http.Error(w, "temporary failure", http.StatusServiceUnavailable) + return + } + writeTestJSON(w, jose.JSONWebKeySet{Keys: keys}) + case "/token": + if p.token == nil { + w.WriteHeader(http.StatusBadRequest) + return + } + p.token(w, r) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +func (p *oidcValidationProvider) manager(t *testing.T, mutate func(*config.AuthConfig)) *Manager { + t.Helper() + cfg := testAuthConfig(p.server.URL) + if mutate != nil { + mutate(&cfg) + } + manager, err := NewManager(context.Background(), cfg, config.ProxyConfig{}, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("new manager: %v", err) + } + return manager +} + +func TestAccessTokenValidationFailures(t *testing.T) { + p := newOIDCValidationProvider(t) + now := time.Now() + valid := map[string]any{ + "iss": p.server.URL, "sub": " user-1 ", "aud": "portal-api", + "exp": now.Add(time.Hour).Unix(), "iat": now.Unix(), "nbf": now.Add(-time.Minute).Unix(), + "org_id": " org-1 ", "scope": ScopeConsentsReadSelf + " " + ScopeConsentsReadSelf + " " + ScopeElementsRead, + } + + tests := []struct { + name string + claims map[string]any + key *rsa.PrivateKey + keyID string + algorithm jose.SignatureAlgorithm + mutateCfg func(*config.AuthConfig) + wantValid bool + wantScopes []string + }{ + {name: "valid and normalized", claims: cloneClaims(valid), key: p.key1, keyID: "key-1", algorithm: jose.RS256, wantValid: true, wantScopes: []string{ScopeConsentsReadSelf, ScopeElementsRead}}, + {name: "invalid signature", claims: cloneClaims(valid), key: p.key2, keyID: "key-1", algorithm: jose.RS256}, + {name: "unsupported algorithm", claims: cloneClaims(valid), key: p.key1, keyID: "key-1", algorithm: jose.PS256}, + {name: "expired", claims: withClaim(valid, "exp", now.Add(-time.Hour).Unix()), key: p.key1, keyID: "key-1", algorithm: jose.RS256}, + {name: "future nbf", claims: withClaim(valid, "nbf", now.Add(10*time.Minute).Unix()), key: p.key1, keyID: "key-1", algorithm: jose.RS256}, + {name: "wrong issuer", claims: withClaim(valid, "iss", "https://wrong.example"), key: p.key1, keyID: "key-1", algorithm: jose.RS256}, + {name: "wrong audience", claims: withClaim(valid, "aud", "wrong-api"), key: p.key1, keyID: "key-1", algorithm: jose.RS256}, + {name: "missing subject", claims: withoutClaim(valid, "sub"), key: p.key1, keyID: "key-1", algorithm: jose.RS256}, + {name: "blank organization", claims: withClaim(valid, "org_id", " "), key: p.key1, keyID: "key-1", algorithm: jose.RS256}, + {name: "malformed organization", claims: withClaim(valid, "org_id", 123), key: p.key1, keyID: "key-1", algorithm: jose.RS256}, + {name: "malformed scopes", claims: withClaim(valid, "scope", 123), key: p.key1, keyID: "key-1", algorithm: jose.RS256}, + { + name: "wrong token type", claims: withClaim(valid, "typ", "refresh"), key: p.key1, keyID: "key-1", algorithm: jose.RS256, + mutateCfg: func(cfg *config.AuthConfig) { + cfg.RequireAccessTokenType = true + cfg.AccessTokenTypeClaim = "typ" + cfg.AccessTokenTypeValue = "access" + }, + }, + { + name: "valid token type", claims: withClaim(valid, "typ", "access"), key: p.key1, keyID: "key-1", algorithm: jose.RS256, wantValid: true, + mutateCfg: func(cfg *config.AuthConfig) { + cfg.RequireAccessTokenType = true + cfg.AccessTokenTypeClaim = "typ" + cfg.AccessTokenTypeValue = "access" + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + manager := p.manager(t, test.mutateCfg) + raw := signTestTokenWithAlgorithm(t, test.key, test.keyID, test.algorithm, test.claims) + principal, _, err := manager.validateAccessToken(context.Background(), raw) + if (err == nil) != test.wantValid { + t.Fatalf("valid=%v, error=%v", test.wantValid, err) + } + if !test.wantValid { + return + } + if principal.UserID != "user-1" || principal.OrgID != "org-1" { + t.Fatalf("claims were not normalized: %#v", principal) + } + for _, scope := range test.wantScopes { + if _, ok := principal.Scopes[scope]; !ok { + t.Errorf("missing normalized scope %q", scope) + } + } + }) + } +} + +func TestAccessTokenArrayScopeClaim(t *testing.T) { + p := newOIDCValidationProvider(t) + manager := p.manager(t, nil) + now := time.Now() + raw := signTestToken(t, p.key1, "key-1", map[string]any{ + "iss": p.server.URL, "sub": "user", "aud": "portal-api", "exp": now.Add(time.Hour).Unix(), + "org_id": "org", "scope": []string{ScopeElementsRead, "", ScopeElementsRead}, + }) + principal, _, err := manager.validateAccessToken(context.Background(), raw) + if err != nil || len(principal.Scopes) != 1 { + t.Fatalf("unexpected array-scope result: %#v, %v", principal, err) + } +} + +func TestIDTokenValidationFailures(t *testing.T) { + p := newOIDCValidationProvider(t) + manager := p.manager(t, nil) + now := time.Now() + valid := map[string]any{ + "iss": p.server.URL, "sub": "user-1", "aud": "portal-client", + "exp": now.Add(time.Hour).Unix(), "iat": now.Unix(), "nbf": now.Add(-time.Minute).Unix(), + } + tests := []struct { + name string + claims map[string]any + key *rsa.PrivateKey + keyID string + algorithm jose.SignatureAlgorithm + wantValid bool + }{ + {name: "valid", claims: cloneClaims(valid), key: p.key1, keyID: "key-1", algorithm: jose.RS256, wantValid: true}, + {name: "invalid signature", claims: cloneClaims(valid), key: p.key2, keyID: "key-1", algorithm: jose.RS256}, + {name: "unsupported algorithm", claims: cloneClaims(valid), key: p.key1, keyID: "key-1", algorithm: jose.PS256}, + {name: "expired", claims: withClaim(valid, "exp", now.Add(-time.Hour).Unix()), key: p.key1, keyID: "key-1", algorithm: jose.RS256}, + {name: "future nbf", claims: withClaim(valid, "nbf", now.Add(10*time.Minute).Unix()), key: p.key1, keyID: "key-1", algorithm: jose.RS256}, + {name: "wrong issuer", claims: withClaim(valid, "iss", "https://wrong.example"), key: p.key1, keyID: "key-1", algorithm: jose.RS256}, + {name: "wrong audience", claims: withClaim(valid, "aud", "wrong-client"), key: p.key1, keyID: "key-1", algorithm: jose.RS256}, + {name: "missing subject", claims: withoutClaim(valid, "sub"), key: p.key1, keyID: "key-1", algorithm: jose.RS256}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + raw := signTestTokenWithAlgorithm(t, test.key, test.keyID, test.algorithm, test.claims) + _, err := manager.validateIDToken(context.Background(), raw) + if (err == nil) != test.wantValid { + t.Fatalf("valid=%v, error=%v", test.wantValid, err) + } + }) + } +} + +func TestJWKSCacheUnknownKeyRotationAndTransientFailure(t *testing.T) { + p := newOIDCValidationProvider(t) + manager := p.manager(t, nil) + now := time.Now() + claims := map[string]any{ + "iss": p.server.URL, "sub": "user", "aud": "portal-api", "exp": now.Add(time.Hour).Unix(), + "org_id": "org", "scope": ScopeElementsRead, + } + + validate := func(key *rsa.PrivateKey, keyID string, wantValid bool) { + t.Helper() + _, _, err := manager.validateAccessToken(context.Background(), signTestToken(t, key, keyID, claims)) + if (err == nil) != wantValid { + t.Fatalf("key %s valid=%v, error=%v", keyID, wantValid, err) + } + } + validate(p.key1, "key-1", true) + validate(p.key1, "key-1", true) + p.mu.Lock() + if p.jwksCalls != 1 { + t.Fatalf("expected cached JWKS after one fetch, got %d", p.jwksCalls) + } + p.keys = append(p.keys, publicJWK(p.key2, "key-2")) + p.mu.Unlock() + validate(p.key2, "key-2", true) + p.mu.Lock() + if p.jwksCalls != 2 { + t.Fatalf("expected unknown kid to refresh JWKS, got %d calls", p.jwksCalls) + } + p.keys = append(p.keys, publicJWK(p.key3, "key-3")) + p.failNextJW = true + p.mu.Unlock() + validate(p.key3, "key-3", false) + validate(p.key3, "key-3", true) +} + +func TestAuthenticationFailureAndAuthorizationFailureSemantics(t *testing.T) { + manager := &Manager{cfg: config.AuthConfig{}, proxyCfg: config.ProxyConfig{}} + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) + + unauthorized := httptest.NewRecorder() + manager.Require(next).ServeHTTP(unauthorized, httptest.NewRequest(http.MethodGet, "/", nil)) + if unauthorized.Code != http.StatusUnauthorized || unauthorized.Header().Get("WWW-Authenticate") != "Bearer" { + t.Fatalf("unexpected authentication failure: %d, %q", unauthorized.Code, unauthorized.Header().Get("WWW-Authenticate")) + } + + manager.proxyCfg = config.ProxyConfig{PlaceholderModeEnabled: true, PlaceholderUserID: "user", PlaceholderOrgID: "org"} + forbidden := httptest.NewRecorder() + manager.Require(next, "missing-scope").ServeHTTP(forbidden, httptest.NewRequest(http.MethodGet, "/", nil)) + if forbidden.Code != http.StatusForbidden || forbidden.Header().Get("WWW-Authenticate") != "" { + t.Fatalf("unexpected authorization failure: %d, %q", forbidden.Code, forbidden.Header().Get("WWW-Authenticate")) + } +} + +func TestSplitBearerAuthenticationRejectionCases(t *testing.T) { + provider := newOIDCValidationProvider(t) + manager := provider.manager(t, nil) + now := time.Now() + raw := signTestToken(t, provider.key1, "key-1", map[string]any{ + "iss": provider.server.URL, "sub": "user", "aud": "portal-api", "exp": now.Add(time.Hour).Unix(), + "org_id": "org", "scope": ScopeElementsRead, + }) + part1, part2, err := splitToken(raw, manager.cfg.MaxTokenPartBytes) + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + authorization []string + cookie string + }{ + {name: "missing part 1", cookie: manager.cfg.AccessTokenPart2Cookie + "=" + part2}, + {name: "missing part 2", authorization: []string{"Bearer " + part1}}, + {name: "complete token injection", authorization: []string{"Bearer " + raw}, cookie: manager.cfg.AccessTokenPart2Cookie + "=" + part2}, + {name: "wrong half order", authorization: []string{"Bearer " + part2}, cookie: manager.cfg.AccessTokenPart2Cookie + "=" + part1}, + {name: "tampered part 1", authorization: []string{"Bearer x" + part1[1:]}, cookie: manager.cfg.AccessTokenPart2Cookie + "=" + part2}, + {name: "tampered part 2", authorization: []string{"Bearer " + part1}, cookie: manager.cfg.AccessTokenPart2Cookie + "=x" + part2[1:]}, + {name: "duplicate part 1", authorization: []string{"Bearer " + part1, "Bearer " + part1}, cookie: manager.cfg.AccessTokenPart2Cookie + "=" + part2}, + {name: "duplicate part 2", authorization: []string{"Bearer " + part1}, cookie: manager.cfg.AccessTokenPart2Cookie + "=" + part2 + "; " + manager.cfg.AccessTokenPart2Cookie + "=" + part2}, + } + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodGet, "/protected", nil) + for _, value := range test.authorization { + request.Header.Add("Authorization", value) + } + if test.cookie != "" { + request.Header.Set("Cookie", test.cookie) + } + recorder := httptest.NewRecorder() + manager.Require(next).ServeHTTP(recorder, request) + if recorder.Code != http.StatusUnauthorized || recorder.Header().Get("WWW-Authenticate") != "Bearer" { + t.Fatalf("got status %d and challenge %q", recorder.Code, recorder.Header().Get("WWW-Authenticate")) + } + }) + } +} + +func TestEveryProtectedPolicyAcceptsReconstructedTokenWithRequiredScope(t *testing.T) { + provider := newOIDCValidationProvider(t) + manager := provider.manager(t, nil) + now := time.Now() + raw := signTestToken(t, provider.key1, "key-1", map[string]any{ + "iss": provider.server.URL, "sub": "user", "aud": "portal-api", "exp": now.Add(time.Hour).Unix(), + "org_id": "org", "scope": strings.Join(AllPortalScopes, " "), + }) + part1, part2, err := splitToken(raw, manager.cfg.MaxTokenPartBytes) + if err != nil { + t.Fatal(err) + } + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + principal, ok := systemcontext.PrincipalFromContext(r.Context()) + if !ok || principal.UserID != "user" || principal.OrgID != "org" { + t.Errorf("missing validated principal: %#v", principal) + } + w.WriteHeader(http.StatusNoContent) + }) + tests := []struct { + method, path, scope string + api bool + }{ + {"GET", "/me/consents", ScopeConsentsReadSelf, false}, + {"GET", "/me/consents/c1", ScopeConsentsReadSelf, false}, + {"POST", "/me/consents/c1/approve", ScopeConsentsWriteSelf, false}, + {"POST", "/me/consents/c1/revoke", ScopeConsentsWriteSelf, false}, + {"GET", "/api/consents", ScopeConsentsReadAny, true}, + {"POST", "/api/consents", ScopeConsentsWriteAny, true}, + {"GET", "/api/consents/attributes", ScopeConsentsReadAny, true}, + {"POST", "/api/consents/validate", ScopeConsentsReadAny, true}, + {"GET", "/api/consents/c1", ScopeConsentsReadAny, true}, + {"PUT", "/api/consents/c1", ScopeConsentsWriteAny, true}, + {"GET", "/api/consents/c1/history", ScopeConsentsReadAny, true}, + {"POST", "/api/consents/c1/revoke", ScopeConsentsWriteAny, true}, + {"GET", "/api/consents/c1/authorizations", ScopeConsentsReadAny, true}, + {"POST", "/api/consents/c1/authorizations", ScopeConsentsWriteAny, true}, + {"GET", "/api/consents/c1/authorizations/a1", ScopeConsentsReadAny, true}, + {"PUT", "/api/consents/c1/authorizations/a1", ScopeConsentsWriteAny, true}, + {"GET", "/api/consent-elements", ScopeElementsRead, true}, + {"POST", "/api/consent-elements", ScopeElementsWrite, true}, + {"GET", "/api/consent-elements/e1", ScopeElementsRead, true}, + {"GET", "/api/consent-elements/e1/versions", ScopeElementsRead, true}, + {"POST", "/api/consent-elements/e1/versions", ScopeElementsWrite, true}, + {"GET", "/api/consent-elements/e1/versions/v1", ScopeElementsRead, true}, + {"DELETE", "/api/consent-elements/e1/versions/v1", ScopeElementsWrite, true}, + {"GET", "/api/consent-purposes", ScopePurposesRead, true}, + {"POST", "/api/consent-purposes", ScopePurposesWrite, true}, + {"GET", "/api/consent-purposes/p1", ScopePurposesRead, true}, + {"GET", "/api/consent-purposes/p1/versions", ScopePurposesRead, true}, + {"POST", "/api/consent-purposes/p1/versions", ScopePurposesWrite, true}, + {"GET", "/api/consent-purposes/p1/versions/v1", ScopePurposesRead, true}, + {"DELETE", "/api/consent-purposes/p1/versions/v1", ScopePurposesWrite, true}, + } + for _, test := range tests { + t.Run(test.method+" "+test.path, func(t *testing.T) { + request := httptest.NewRequest(test.method, test.path, nil) + request.Header.Set("Authorization", "Bearer "+part1) + request.AddCookie(&http.Cookie{Name: manager.cfg.AccessTokenPart2Cookie, Value: part2}) + recorder := httptest.NewRecorder() + if test.api { + manager.RequireAPI(next).ServeHTTP(recorder, request) + } else { + manager.Require(next, test.scope).ServeHTTP(recorder, request) + } + if recorder.Code != http.StatusNoContent { + t.Fatalf("got %d: %s", recorder.Code, recorder.Body.String()) + } + }) + } +} + +func TestAPIRequiresAnyScopeAndCarriesOrganizationPrincipal(t *testing.T) { + provider := newOIDCValidationProvider(t) + manager := provider.manager(t, nil) + now := time.Now() + requestWithScopes := func(scopes string) *http.Request { + t.Helper() + raw := signTestToken(t, provider.key1, "key-1", map[string]any{ + "iss": provider.server.URL, "sub": "user", "aud": "portal-api", "exp": now.Add(time.Hour).Unix(), + "org_id": "trusted-org", "scope": scopes, + }) + part1, part2, err := splitToken(raw, manager.cfg.MaxTokenPartBytes) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "/api/consents?org-id=spoofed", nil) + request.Header.Set("Authorization", "Bearer "+part1) + request.AddCookie(&http.Cookie{Name: manager.cfg.AccessTokenPart2Cookie, Value: part2}) + return request + } + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + principal, ok := systemcontext.PrincipalFromContext(r.Context()) + if !ok || principal.OrgID != "trusted-org" { + t.Fatalf("unexpected principal: %#v", principal) + } + w.WriteHeader(http.StatusNoContent) + }) + + forbidden := httptest.NewRecorder() + manager.RequireAPI(next).ServeHTTP(forbidden, requestWithScopes(ScopeConsentsReadSelf)) + if forbidden.Code != http.StatusForbidden { + t.Fatalf("self scope must not authorize /api route: %d", forbidden.Code) + } + authorized := httptest.NewRecorder() + manager.RequireAPI(next).ServeHTTP(authorized, requestWithScopes(ScopeConsentsReadAny)) + if authorized.Code != http.StatusNoContent { + t.Fatalf("any scope should authorize /api route: %d", authorized.Code) + } +} + +func generateRSAKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + return key +} + +func publicJWK(key *rsa.PrivateKey, keyID string) jose.JSONWebKey { + return jose.JSONWebKey{Key: &key.PublicKey, KeyID: keyID, Algorithm: "RS256", Use: "sig"} +} + +func signTestTokenWithAlgorithm(t *testing.T, key *rsa.PrivateKey, keyID string, algorithm jose.SignatureAlgorithm, claims map[string]any) string { + t.Helper() + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: algorithm, Key: key}, (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", keyID)) + if err != nil { + t.Fatal(err) + } + payload, err := json.Marshal(claims) + if err != nil { + t.Fatal(err) + } + signed, err := signer.Sign(payload) + if err != nil { + t.Fatal(err) + } + serialized, err := signed.CompactSerialize() + if err != nil { + t.Fatal(err) + } + return serialized +} + +func cloneClaims(source map[string]any) map[string]any { + result := make(map[string]any, len(source)) + for key, value := range source { + result[key] = value + } + return result +} + +func withClaim(source map[string]any, name string, value any) map[string]any { + result := cloneClaims(source) + result[name] = value + return result +} + +func withoutClaim(source map[string]any, name string) map[string]any { + result := cloneClaims(source) + delete(result, name) + return result +} diff --git a/portal/backend/internal/system/config/config.go b/portal/backend/internal/system/config/config.go index a95de91f..314b6c65 100644 --- a/portal/backend/internal/system/config/config.go +++ b/portal/backend/internal/system/config/config.go @@ -22,6 +22,7 @@ package config import ( "encoding/json" "fmt" + "net/http" "net/url" "os" "strings" @@ -39,9 +40,46 @@ type Config struct { Server ServerConfig `koanf:"server"` Log LogConfig `koanf:"log"` CORS CORSConfig `koanf:"cors"` + Auth AuthConfig `koanf:"auth"` Proxy ProxyConfig `koanf:"proxy"` } +// AuthConfig contains OIDC confidential-client, JWT validation, and split-cookie settings. +type AuthConfig struct { + Enabled bool `koanf:"enabled"` + IssuerURL string `koanf:"issuer_url"` + ClientID string `koanf:"client_id"` + ClientSecret string `koanf:"-"` + PortalURL string `koanf:"portal_url"` + RedirectURI string `koanf:"redirect_uri"` + PostLogoutRedirectURI string `koanf:"post_logout_redirect_uri"` + Scopes []string `koanf:"scopes"` + ResourceAudience string `koanf:"resource_audience"` + AllowedSigningAlgorithms []string `koanf:"allowed_signing_algorithms"` + HTTPTimeout time.Duration `koanf:"http_timeout"` + RefreshTimeout time.Duration `koanf:"refresh_timeout"` + ScopeClaim string `koanf:"scope_claim"` + OrgIDClaim string `koanf:"org_id_claim"` + RequireAccessTokenType bool `koanf:"require_access_token_type"` + AccessTokenTypeClaim string `koanf:"access_token_type_claim"` + AccessTokenTypeValue string `koanf:"access_token_type_value"` + AccessTokenPart1Cookie string `koanf:"access_token_part1_cookie"` + AccessTokenPart2Cookie string `koanf:"access_token_part2_cookie"` + RefreshTokenPart1Cookie string `koanf:"refresh_token_part1_cookie"` + RefreshTokenPart2Cookie string `koanf:"refresh_token_part2_cookie"` + IDTokenPart1Cookie string `koanf:"id_token_part1_cookie"` + IDTokenPart2Cookie string `koanf:"id_token_part2_cookie"` + OAuthStateCookie string `koanf:"oauth_state_cookie"` + PKCEVerifierCookie string `koanf:"pkce_verifier_cookie"` + CookieSecure bool `koanf:"cookie_secure"` + CookieSameSite string `koanf:"cookie_same_site"` + ClockSkew time.Duration `koanf:"clock_skew"` + LoginTransactionMaxAgeSeconds int `koanf:"login_transaction_max_age_seconds"` + RefreshCookieMaxAgeSeconds int `koanf:"refresh_cookie_max_age_seconds"` + MaxTokenPartBytes int `koanf:"max_token_part_bytes"` + MaxReconstructedTokenBytes int `koanf:"max_reconstructed_token_bytes"` +} + // CORSConfig contains browser cross-origin policy settings for local/frontend integration. type CORSConfig struct { AllowedOrigins []string `koanf:"allowed_origins"` @@ -72,12 +110,10 @@ type ProxyConfig struct { MaxRequestBytes int64 `koanf:"max_request_bytes"` MaxResponseBytes int64 `koanf:"max_response_bytes"` - PlaceholderModeEnabled bool `koanf:"placeholder_mode_enabled"` - PlaceholderUserID string `koanf:"placeholder_user_id"` - PlaceholderOrgID string `koanf:"placeholder_org_id"` - PlaceholderGroupID string `koanf:"placeholder_group_id"` - - AllowedPassthrough []string `koanf:"allowed_passthrough_methods"` + PlaceholderModeEnabled bool `koanf:"placeholder_mode_enabled"` + PlaceholderUserID string `koanf:"placeholder_user_id"` + PlaceholderOrgID string `koanf:"placeholder_org_id"` + AllowedPassthrough []string `koanf:"allowed_passthrough_methods"` } // Load initializes configuration from defaults, optional file, and environment variables. @@ -128,6 +164,15 @@ func Load() (*Config, error) { if rawHeaders := os.Getenv("BFF_CORS__ALLOWED_HEADERS"); rawHeaders != "" { cfg.CORS.AllowedHeaders = ParseCSV(rawHeaders) } + if rawScopes := os.Getenv("BFF_AUTH__SCOPES"); rawScopes != "" { + cfg.Auth.Scopes = strings.Fields(rawScopes) + } + if rawAlgorithms := os.Getenv("BFF_AUTH__ALLOWED_SIGNING_ALGORITHMS"); rawAlgorithms != "" { + cfg.Auth.AllowedSigningAlgorithms = ParseCSV(rawAlgorithms) + } + // Client secrets are intentionally sourced only from the process environment. + // They are never loaded from the optional YAML configuration file. + cfg.Auth.ClientSecret = os.Getenv("BFF_AUTH__CLIENT_SECRET") return &cfg, validate(cfg) } @@ -163,12 +208,87 @@ func setDefaults(k *koanf.Koanf) error { if err := k.Set("cors.allowed_methods", []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}); err != nil { return err } - if err := k.Set("cors.allowed_headers", []string{"Content-Type", "X-Correlation-ID"}); err != nil { + if err := k.Set("cors.allowed_headers", []string{"Content-Type", "Authorization", "X-Correlation-ID"}); err != nil { return err } if err := k.Set("cors.allow_credentials", false); err != nil { return err } + if err := k.Set("auth.enabled", false); err != nil { + return err + } + if err := k.Set("auth.scopes", []string{"openid", "profile"}); err != nil { + return err + } + if err := k.Set("auth.allowed_signing_algorithms", []string{"RS256"}); err != nil { + return err + } + if err := k.Set("auth.http_timeout", "5s"); err != nil { + return err + } + if err := k.Set("auth.refresh_timeout", "10s"); err != nil { + return err + } + if err := k.Set("auth.clock_skew", "30s"); err != nil { + return err + } + if err := k.Set("auth.scope_claim", "scope"); err != nil { + return err + } + if err := k.Set("auth.org_id_claim", "org_id"); err != nil { + return err + } + if err := k.Set("auth.require_access_token_type", false); err != nil { + return err + } + if err := k.Set("auth.access_token_type_claim", "token_type"); err != nil { + return err + } + if err := k.Set("auth.access_token_type_value", "access_token"); err != nil { + return err + } + if err := k.Set("auth.access_token_part1_cookie", "portal-at-p1"); err != nil { + return err + } + if err := k.Set("auth.access_token_part2_cookie", "portal-at-p2"); err != nil { + return err + } + if err := k.Set("auth.refresh_token_part1_cookie", "portal-rt-p1"); err != nil { + return err + } + if err := k.Set("auth.refresh_token_part2_cookie", "portal-rt-p2"); err != nil { + return err + } + if err := k.Set("auth.id_token_part1_cookie", "portal-id-p1"); err != nil { + return err + } + if err := k.Set("auth.id_token_part2_cookie", "portal-id-p2"); err != nil { + return err + } + if err := k.Set("auth.oauth_state_cookie", "portal-oauth-state"); err != nil { + return err + } + if err := k.Set("auth.pkce_verifier_cookie", "portal-pkce-verifier"); err != nil { + return err + } + if err := k.Set("auth.cookie_secure", false); err != nil { + return err + } + if err := k.Set("auth.cookie_same_site", "Lax"); err != nil { + return err + } + if err := k.Set("auth.refresh_cookie_max_age_seconds", 86400); err != nil { + return err + } + if err := k.Set("auth.login_transaction_max_age_seconds", 600); err != nil { + return err + } + if err := k.Set("auth.max_token_part_bytes", 3800); err != nil { + return err + } + if err := k.Set("auth.max_reconstructed_token_bytes", 7600); err != nil { + return err + } if err := k.Set("proxy.openfgc_api_url", "http://localhost:9090"); err != nil { return err } @@ -190,9 +310,6 @@ func setDefaults(k *koanf.Koanf) error { if err := k.Set("proxy.placeholder_org_id", ""); err != nil { return err } - if err := k.Set("proxy.placeholder_group_id", ""); err != nil { - return err - } if err := k.Set("proxy.allowed_passthrough_methods", []string{"GET", "POST", "PUT", "DELETE"}); err != nil { return err } @@ -261,21 +378,116 @@ func validate(cfg Config) error { if cfg.Proxy.PlaceholderModeEnabled && strings.EqualFold(cfg.Env, "production") { return fmt.Errorf("proxy.placeholder_mode_enabled cannot be true in production") } + if err := validateAuth(cfg); err != nil { + return err + } if !cfg.Proxy.PlaceholderModeEnabled && cfg.Proxy.PlaceholderUserID != "" { return fmt.Errorf("proxy.placeholder_user_id must be empty when placeholder mode is disabled") } if !cfg.Proxy.PlaceholderModeEnabled && cfg.Proxy.PlaceholderOrgID != "" { return fmt.Errorf("proxy.placeholder_org_id must be empty when placeholder mode is disabled") } - if !cfg.Proxy.PlaceholderModeEnabled && cfg.Proxy.PlaceholderGroupID != "" { - return fmt.Errorf("proxy.placeholder_group_id must be empty when placeholder mode is disabled") - } if len(cfg.Proxy.AllowedPassthrough) == 0 { return fmt.Errorf("proxy.allowed_passthrough_methods must not be empty") } return nil } +func validateAuth(cfg Config) error { + if !cfg.Auth.Enabled { + if strings.EqualFold(cfg.Env, "production") { + return fmt.Errorf("auth.enabled must be true in production") + } + return nil + } + required := map[string]string{ + "auth.issuer_url": cfg.Auth.IssuerURL, + "auth.client_id": cfg.Auth.ClientID, + "auth.client_secret (BFF_AUTH__CLIENT_SECRET)": cfg.Auth.ClientSecret, + "auth.portal_url": cfg.Auth.PortalURL, + "auth.redirect_uri": cfg.Auth.RedirectURI, + "auth.post_logout_redirect_uri": cfg.Auth.PostLogoutRedirectURI, + "auth.resource_audience": cfg.Auth.ResourceAudience, + "auth.scope_claim": cfg.Auth.ScopeClaim, + "auth.org_id_claim": cfg.Auth.OrgIDClaim, + } + for name, value := range required { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("%s must not be empty when auth is enabled", name) + } + } + for name, value := range map[string]string{ + "auth.issuer_url": cfg.Auth.IssuerURL, + "auth.portal_url": cfg.Auth.PortalURL, + "auth.redirect_uri": cfg.Auth.RedirectURI, + "auth.post_logout_redirect_uri": cfg.Auth.PostLogoutRedirectURI, + } { + if err := validateAbsoluteHTTPURL(value, strings.EqualFold(cfg.Env, "production")); err != nil { + return fmt.Errorf("%s %w", name, err) + } + } + if len(cfg.Auth.Scopes) == 0 || len(cfg.Auth.AllowedSigningAlgorithms) == 0 { + return fmt.Errorf("auth scopes and allowed signing algorithms must not be empty") + } + if cfg.Auth.HTTPTimeout <= 0 || cfg.Auth.RefreshTimeout <= 0 { + return fmt.Errorf("auth HTTP timeouts must be > 0") + } + if cfg.Auth.ClockSkew < 0 { + return fmt.Errorf("auth.clock_skew must be >= 0") + } + if cfg.Auth.MaxTokenPartBytes <= 0 || cfg.Auth.MaxReconstructedTokenBytes < cfg.Auth.MaxTokenPartBytes*2 { + return fmt.Errorf("auth token size limits are invalid") + } + if cfg.Auth.RefreshCookieMaxAgeSeconds <= 0 { + return fmt.Errorf("auth.refresh_cookie_max_age_seconds must be > 0") + } + if cfg.Auth.LoginTransactionMaxAgeSeconds <= 0 || cfg.Auth.LoginTransactionMaxAgeSeconds > 600 { + return fmt.Errorf("auth.login_transaction_max_age_seconds must be between 1 and 600") + } + switch strings.ToLower(cfg.Auth.CookieSameSite) { + case "strict", "lax", "none": + default: + return fmt.Errorf("auth.cookie_same_site must be Strict, Lax, or None") + } + if strings.EqualFold(cfg.Auth.CookieSameSite, "none") && !cfg.Auth.CookieSecure { + return fmt.Errorf("auth.cookie_secure must be true when SameSite=None") + } + cookieNames := []string{ + cfg.Auth.AccessTokenPart1Cookie, cfg.Auth.AccessTokenPart2Cookie, + cfg.Auth.RefreshTokenPart1Cookie, cfg.Auth.RefreshTokenPart2Cookie, + cfg.Auth.IDTokenPart1Cookie, cfg.Auth.IDTokenPart2Cookie, + cfg.Auth.OAuthStateCookie, cfg.Auth.PKCEVerifierCookie, + } + seenCookieNames := make(map[string]struct{}, len(cookieNames)) + for _, name := range cookieNames { + if strings.TrimSpace(name) == "" || (&http.Cookie{Name: name, Value: "value"}).Valid() != nil { + return fmt.Errorf("auth cookie names must be valid and non-empty") + } + if _, duplicate := seenCookieNames[name]; duplicate { + return fmt.Errorf("auth cookie names must be unique") + } + seenCookieNames[name] = struct{}{} + } + if strings.EqualFold(cfg.Env, "production") && !cfg.Auth.CookieSecure { + return fmt.Errorf("auth.cookie_secure must be true in production") + } + if cfg.Proxy.PlaceholderModeEnabled { + return fmt.Errorf("proxy placeholder mode and auth cannot both be enabled") + } + return nil +} + +func validateAbsoluteHTTPURL(raw string, requireHTTPS bool) error { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return fmt.Errorf("must be an absolute HTTP(S) URL") + } + if requireHTTPS && parsed.Scheme != "https" { + return fmt.Errorf("must use HTTPS in production") + } + return nil +} + // ValidateOpenFGCAPIURL validates and parses the configured upstream OpenFGC API URL. func ValidateOpenFGCAPIURL(rawURL string) (*url.URL, error) { upstream := strings.TrimSpace(rawURL) diff --git a/portal/backend/internal/system/config/config_test.go b/portal/backend/internal/system/config/config_test.go index 5d80c953..047f106a 100644 --- a/portal/backend/internal/system/config/config_test.go +++ b/portal/backend/internal/system/config/config_test.go @@ -20,8 +20,10 @@ package config import ( "os" + "path/filepath" "strings" "testing" + "time" ) func TestLoadFromEnv(t *testing.T) { @@ -128,7 +130,6 @@ func TestPlaceholderValuesRejectedWhenModeDisabled(t *testing.T) { }{ {name: "user id", envName: "BFF_PROXY__PLACEHOLDER_USER_ID", errText: "proxy.placeholder_user_id must be empty"}, {name: "org id", envName: "BFF_PROXY__PLACEHOLDER_ORG_ID", errText: "proxy.placeholder_org_id must be empty"}, - {name: "group id", envName: "BFF_PROXY__PLACEHOLDER_GROUP_ID", errText: "proxy.placeholder_group_id must be empty"}, } for _, tt := range tests { @@ -201,3 +202,127 @@ func TestMaxResponseBytesMustBePositive(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + +func TestAuthClientSecretIsEnvironmentOnly(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.yaml") + contents := ` +auth: + enabled: true + issuer_url: https://idp.example.com + client_id: portal-client + client_secret: file-secret-must-be-ignored + portal_url: https://portal.example.com/consents + redirect_uri: https://portal.example.com/auth/callback + post_logout_redirect_uri: https://portal.example.com/ + resource_audience: portal-api +` + if err := os.WriteFile(configPath, []byte(contents), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("BFF_CONFIG_FILE", configPath) + + if _, err := Load(); err == nil || !strings.Contains(err.Error(), "BFF_AUTH__CLIENT_SECRET") { + t.Fatalf("expected file secret to be ignored, got %v", err) + } + + t.Setenv("BFF_AUTH__CLIENT_SECRET", "environment-secret") + cfg, err := Load() + if err != nil { + t.Fatalf("expected environment secret to load: %v", err) + } + if cfg.Auth.ClientSecret != "environment-secret" { + t.Fatalf("unexpected client secret source") + } +} + +func TestProductionAuthURLsRequireHTTPS(t *testing.T) { + tests := []struct { + name string + field string + update func(*AuthConfig) + }{ + {name: "issuer URL", field: "auth.issuer_url", update: func(auth *AuthConfig) { auth.IssuerURL = "http://idp.example.com" }}, + {name: "portal URL", field: "auth.portal_url", update: func(auth *AuthConfig) { auth.PortalURL = "http://portal.example.com/consents" }}, + {name: "redirect URI", field: "auth.redirect_uri", update: func(auth *AuthConfig) { auth.RedirectURI = "http://portal.example.com/auth/callback" }}, + {name: "post-logout redirect URI", field: "auth.post_logout_redirect_uri", update: func(auth *AuthConfig) { auth.PostLogoutRedirectURI = "http://portal.example.com/" }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := validAuthValidationConfig("production") + test.update(&cfg.Auth) + + err := validateAuth(cfg) + if err == nil { + t.Fatal("expected production HTTP URL to be rejected") + } + if !strings.Contains(err.Error(), test.field) || !strings.Contains(err.Error(), "must use HTTPS in production") { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestProductionAuthURLsAcceptHTTPS(t *testing.T) { + if err := validateAuth(validAuthValidationConfig("PrOdUcTiOn")); err != nil { + t.Fatalf("expected production HTTPS URLs to be accepted: %v", err) + } +} + +func TestDevelopmentAuthURLsAllowHTTP(t *testing.T) { + cfg := validAuthValidationConfig("development") + cfg.Auth.IssuerURL = "http://localhost:9443/oauth2/token" + cfg.Auth.PortalURL = "http://localhost:5173/consents" + cfg.Auth.RedirectURI = "http://localhost:8080/auth/callback" + cfg.Auth.PostLogoutRedirectURI = "http://localhost:5173/" + cfg.Auth.CookieSecure = false + + if err := validateAuth(cfg); err != nil { + t.Fatalf("expected development HTTP URLs to be accepted: %v", err) + } +} + +func TestLoginTransactionConfigurationValidation(t *testing.T) { + t.Run("maximum age", func(t *testing.T) { + cfg := validAuthValidationConfig("development") + cfg.Auth.LoginTransactionMaxAgeSeconds = 601 + if err := validateAuth(cfg); err == nil || !strings.Contains(err.Error(), "login_transaction_max_age_seconds") { + t.Fatalf("expected invalid transaction maximum age, got %v", err) + } + }) + + t.Run("unique cookie names", func(t *testing.T) { + cfg := validAuthValidationConfig("development") + cfg.Auth.PKCEVerifierCookie = cfg.Auth.OAuthStateCookie + if err := validateAuth(cfg); err == nil || !strings.Contains(err.Error(), "cookie names must be unique") { + t.Fatalf("expected duplicate transaction cookie name to fail, got %v", err) + } + }) +} + +func TestAuthURLsRemainAbsoluteHTTPURLsInEveryEnvironment(t *testing.T) { + for _, value := range []string{"/relative", "ftp://idp.example.com", "https:///missing-host"} { + if err := validateAbsoluteHTTPURL(value, false); err == nil || !strings.Contains(err.Error(), "absolute HTTP(S) URL") { + t.Fatalf("expected %q to be rejected, got %v", value, err) + } + } +} + +func validAuthValidationConfig(environment string) Config { + return Config{ + Env: environment, + Auth: AuthConfig{ + Enabled: true, IssuerURL: "https://idp.example.com", ClientID: "portal-client", ClientSecret: "secret", + PortalURL: "https://portal.example.com/consents", RedirectURI: "https://bff.example.com/auth/callback", + PostLogoutRedirectURI: "https://portal.example.com/", Scopes: []string{"openid"}, ResourceAudience: "portal-api", + AllowedSigningAlgorithms: []string{"RS256"}, HTTPTimeout: 5 * time.Second, RefreshTimeout: 5 * time.Second, + ScopeClaim: "scope", OrgIDClaim: "org_id", CookieSecure: true, CookieSameSite: "Lax", + RefreshCookieMaxAgeSeconds: 3600, MaxTokenPartBytes: 3800, MaxReconstructedTokenBytes: 7600, + AccessTokenPart1Cookie: "at-p1", AccessTokenPart2Cookie: "at-p2", + RefreshTokenPart1Cookie: "rt-p1", RefreshTokenPart2Cookie: "rt-p2", + IDTokenPart1Cookie: "id-p1", IDTokenPart2Cookie: "id-p2", + OAuthStateCookie: "oauth-state", PKCEVerifierCookie: "pkce-verifier", + LoginTransactionMaxAgeSeconds: 600, + }, + } +} diff --git a/portal/backend/internal/system/context/user.go b/portal/backend/internal/system/context/user.go index 89a23f7d..86207724 100644 --- a/portal/backend/internal/system/context/user.go +++ b/portal/backend/internal/system/context/user.go @@ -24,27 +24,28 @@ import ( "strings" ) -type userIDKey struct{} +type principalKey struct{} -// WithUserID stores the effective user ID in request context. -func WithUserID(ctx context.Context, userID string) context.Context { - return context.WithValue(ctx, userIDKey{}, userID) +// Principal contains identity and authorization data derived from a validated token. +type Principal struct { + UserID string + OrgID string + Scopes map[string]struct{} } -// UserIDFromContext returns the effective user ID previously resolved by middleware. -func UserIDFromContext(ctx context.Context) (string, bool) { - if ctx == nil { - return "", false - } +// WithPrincipal stores an authenticated principal in request context. +func WithPrincipal(ctx context.Context, principal Principal) context.Context { + return context.WithValue(ctx, principalKey{}, principal) +} - value, ok := ctx.Value(userIDKey{}).(string) - if !ok { - return "", false +// PrincipalFromContext returns the authenticated principal. +func PrincipalFromContext(ctx context.Context) (Principal, bool) { + if ctx == nil { + return Principal{}, false } - value = strings.TrimSpace(value) - if value == "" { - return "", false + principal, ok := ctx.Value(principalKey{}).(Principal) + if !ok || strings.TrimSpace(principal.UserID) == "" || strings.TrimSpace(principal.OrgID) == "" { + return Principal{}, false } - - return value, true + return principal, true } diff --git a/portal/backend/internal/system/context/user_test.go b/portal/backend/internal/system/context/user_test.go new file mode 100644 index 00000000..9747345e --- /dev/null +++ b/portal/backend/internal/system/context/user_test.go @@ -0,0 +1,68 @@ +/* + * 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 context + +import ( + "context" + "testing" +) + +func TestPrincipalContextRoundTrip(t *testing.T) { + want := Principal{ + UserID: "user-1", + OrgID: "org-1", + Scopes: map[string]struct{}{"consents:read:self": {}}, + } + + got, ok := PrincipalFromContext(WithPrincipal(context.Background(), want)) + if !ok { + t.Fatal("expected principal in context") + } + if got.UserID != want.UserID || got.OrgID != want.OrgID { + t.Fatalf("unexpected principal: %#v", got) + } + if _, ok := got.Scopes["consents:read:self"]; !ok { + t.Fatal("expected principal scopes to be preserved") + } +} + +func TestPrincipalFromContextRejectsMissingIdentity(t *testing.T) { + tests := []struct { + name string + ctx context.Context + principal Principal + }{ + {name: "nil context", ctx: nil}, + {name: "missing principal", ctx: context.Background()}, + {name: "missing user", ctx: context.Background(), principal: Principal{OrgID: "org-1"}}, + {name: "missing organization", ctx: context.Background(), principal: Principal{UserID: "user-1"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx := test.ctx + if test.principal.UserID != "" || test.principal.OrgID != "" { + ctx = WithPrincipal(ctx, test.principal) + } + if _, ok := PrincipalFromContext(ctx); ok { + t.Fatal("expected principal lookup to fail") + } + }) + } +} diff --git a/portal/backend/internal/system/middleware/cors_test.go b/portal/backend/internal/system/middleware/cors_test.go index 83e2e4dc..0cd62ce5 100644 --- a/portal/backend/internal/system/middleware/cors_test.go +++ b/portal/backend/internal/system/middleware/cors_test.go @@ -143,6 +143,39 @@ func TestCORSMiddleware_HandlesPreflight(t *testing.T) { } } +func TestCORSMiddleware_PreflightExplicitlyAllowsAuthorizationWithCredentials(t *testing.T) { + nextCalled := false + handler := CORS(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + }), CORSOptions{ + AllowedOrigins: []string{"https://portal.example"}, + AllowedMethods: []string{"GET", "POST", "OPTIONS"}, + AllowedHeaders: []string{"Content-Type", "Authorization", "X-Correlation-ID"}, + AllowCredentials: true, + }) + + req := httptest.NewRequest(http.MethodOptions, "https://bff.example/me/consents", nil) + req.Header.Set("Origin", "https://portal.example") + req.Header.Set("Access-Control-Request-Method", http.MethodGet) + req.Header.Set("Access-Control-Request-Headers", "Authorization") + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + + if nextCalled || res.Code != http.StatusNoContent { + t.Fatalf("unexpected preflight handling: next=%v status=%d", nextCalled, res.Code) + } + if got := res.Header().Get("Access-Control-Allow-Origin"); got != "https://portal.example" { + t.Fatalf("unexpected allowed origin %q", got) + } + if got := res.Header().Get("Access-Control-Allow-Credentials"); got != "true" { + t.Fatalf("unexpected credentials header %q", got) + } + if got := res.Header().Get("Access-Control-Allow-Headers"); got != "Content-Type, Authorization, X-Correlation-ID" { + t.Fatalf("unexpected allowed headers %q", got) + } +} + func TestCORSMiddleware_AppendsVaryOrigin(t *testing.T) { next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/portal/backend/internal/system/middleware/userid.go b/portal/backend/internal/system/middleware/userid.go deleted file mode 100644 index f3129159..00000000 --- a/portal/backend/internal/system/middleware/userid.go +++ /dev/null @@ -1,68 +0,0 @@ -/* - * 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 middleware contains HTTP middleware helpers used by the BFF. -package middleware - -import ( - "encoding/json" - "net/http" - "strings" - - systemcontext "github.com/wso2/openfgc/portal/backend/internal/system/context" -) - -// UserIDOptions configures placeholder-based user ID resolution for /me routes. -type UserIDOptions struct { - PlaceholderModeEnabled bool - PlaceholderUserID string - Environment string -} - -type userIDErrorResponse struct { - Code string `json:"code"` - Message string `json:"message"` -} - -// UserID resolves the effective user ID once and stores it in request context. -func UserID(next http.Handler, opts UserIDOptions) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !opts.PlaceholderModeEnabled { - message := "identity unavailable" - if !strings.EqualFold(strings.TrimSpace(opts.Environment), "production") { - message += "; consider enabling placeholder identity mode for development" - } - writeUserIDError(w, http.StatusServiceUnavailable, "IDENTITY_UNAVAILABLE", message) - return - } - - userID := strings.TrimSpace(opts.PlaceholderUserID) - if userID == "" { - writeUserIDError(w, http.StatusServiceUnavailable, "PLACEHOLDER_UNAVAILABLE", "placeholder identity unavailable") - return - } - - next.ServeHTTP(w, r.WithContext(systemcontext.WithUserID(r.Context(), userID))) - }) -} - -func writeUserIDError(w http.ResponseWriter, status int, code, message string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(userIDErrorResponse{Code: code, Message: message}) -} diff --git a/portal/backend/internal/system/middleware/userid_test.go b/portal/backend/internal/system/middleware/userid_test.go deleted file mode 100644 index 035c1313..00000000 --- a/portal/backend/internal/system/middleware/userid_test.go +++ /dev/null @@ -1,178 +0,0 @@ -/* - * 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 middleware - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - systemcontext "github.com/wso2/openfgc/portal/backend/internal/system/context" -) - -func TestUserIDMiddleware_InsertsUserIDIntoContext(t *testing.T) { - nextCalled := false - next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - nextCalled = true - userID, ok := systemcontext.UserIDFromContext(r.Context()) - if !ok { - t.Fatal("expected user id in context") - } - if userID != "user@example.com" { - t.Fatalf("expected user@example.com, got %s", userID) - } - w.WriteHeader(http.StatusNoContent) - }) - - handler := UserID(next, UserIDOptions{ - PlaceholderModeEnabled: true, - PlaceholderUserID: "user@example.com", - }) - - req := httptest.NewRequest(http.MethodGet, "/me/consents", nil) - res := httptest.NewRecorder() - - handler.ServeHTTP(res, req) - - if !nextCalled { - t.Fatal("expected next handler to be called") - } - if res.Code != http.StatusNoContent { - t.Fatalf("expected status 204, got %d", res.Code) - } -} - -func TestUserIDMiddleware_Returns503WhenPlaceholderModeDisabled(t *testing.T) { - nextCalled := false - next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - nextCalled = true - w.WriteHeader(http.StatusNoContent) - }) - - handler := UserID(next, UserIDOptions{ - PlaceholderModeEnabled: false, - PlaceholderUserID: "", - Environment: "development", - }) - - req := httptest.NewRequest(http.MethodGet, "/me/consents", nil) - res := httptest.NewRecorder() - - handler.ServeHTTP(res, req) - - if nextCalled { - t.Fatal("expected next handler not to be called") - } - if res.Code != http.StatusServiceUnavailable { - t.Fatalf("expected status 503, got %d", res.Code) - } - - var payload map[string]any - if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { - t.Fatalf("expected json payload: %v", err) - } - if payload["code"] != "IDENTITY_UNAVAILABLE" { - t.Fatalf("expected IDENTITY_UNAVAILABLE, got %v", payload["code"]) - } - message, ok := payload["message"].(string) - if !ok { - t.Fatalf("expected string message, got %T", payload["message"]) - } - if message != "identity unavailable; consider enabling placeholder identity mode for development" { - t.Fatalf("unexpected message: %s", message) - } -} - -func TestUserIDMiddleware_ProductionModeDoesNotIncludeDevelopmentHint(t *testing.T) { - nextCalled := false - next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - nextCalled = true - w.WriteHeader(http.StatusNoContent) - }) - - handler := UserID(next, UserIDOptions{ - PlaceholderModeEnabled: false, - PlaceholderUserID: "", - Environment: "production", - }) - - req := httptest.NewRequest(http.MethodGet, "/me/consents", nil) - res := httptest.NewRecorder() - - handler.ServeHTTP(res, req) - - if nextCalled { - t.Fatal("expected next handler not to be called") - } - if res.Code != http.StatusServiceUnavailable { - t.Fatalf("expected status 503, got %d", res.Code) - } - - var payload map[string]any - if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { - t.Fatalf("expected json payload: %v", err) - } - if payload["code"] != "IDENTITY_UNAVAILABLE" { - t.Fatalf("expected IDENTITY_UNAVAILABLE, got %v", payload["code"]) - } - if payload["message"] != "identity unavailable" { - t.Fatalf("expected base production message, got %v", payload["message"]) - } -} - -func TestUserIDMiddleware_Returns503WhenUserIDMissing(t *testing.T) { - nextCalled := false - next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - nextCalled = true - w.WriteHeader(http.StatusNoContent) - }) - - handler := UserID(next, UserIDOptions{ - PlaceholderModeEnabled: true, - PlaceholderUserID: " ", - }) - - req := httptest.NewRequest(http.MethodGet, "/me/consents", nil) - res := httptest.NewRecorder() - - handler.ServeHTTP(res, req) - - if nextCalled { - t.Fatal("expected next handler not to be called") - } - if res.Code != http.StatusServiceUnavailable { - t.Fatalf("expected status 503, got %d", res.Code) - } - - var payload map[string]any - if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { - t.Fatalf("expected json payload: %v", err) - } - if payload["code"] != "PLACEHOLDER_UNAVAILABLE" { - t.Fatalf("expected PLACEHOLDER_UNAVAILABLE, got %v", payload["code"]) - } -} - -func TestUserIDFromContext_ReturnsFalseWhenMissing(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/me/consents", nil) - if _, ok := systemcontext.UserIDFromContext(req.Context()); ok { - t.Fatal("expected false for missing user id") - } -} diff --git a/portal/backend/openapi/bff.yaml b/portal/backend/openapi/portal-backend.yaml similarity index 68% rename from portal/backend/openapi/bff.yaml rename to portal/backend/openapi/portal-backend.yaml index 306b5e0b..9bfe2e6e 100644 --- a/portal/backend/openapi/bff.yaml +++ b/portal/backend/openapi/portal-backend.yaml @@ -9,8 +9,12 @@ info: consent-management API. servers: - url: http://localhost:8080 +security: + - PortalOAuthDocumentation: [] + AccessTokenPart2: [] tags: - name: Health + - name: Authentication - name: User - name: Passthrough paths: @@ -18,25 +22,98 @@ paths: get: tags: [Health] summary: Service health + security: [] responses: '200': { description: Service healthy } /health/liveness: get: tags: [Health] summary: Liveness check + security: [] responses: '200': { description: Service live } /health/readiness: get: tags: [Health] summary: Readiness check + security: [] responses: '200': { description: Service ready } + /auth/login: + get: + tags: [Authentication] + summary: Start OIDC login + description: >- + Creates a short-lived, one-time login transaction in secure HttpOnly + cookies and redirects with OAuth state and an S256 PKCE challenge. + security: [] + responses: + '302': + description: Redirect to the configured identity provider. + /auth/callback: + get: + tags: [Authentication] + summary: Complete OIDC authorization-code login + security: [] + parameters: + - in: query + name: code + required: true + schema: { type: string } + - in: query + name: state + required: true + description: Must match the one-time state cookie issued by `/auth/login`. + schema: { type: string } + responses: + '302': + description: >- + Consumes the login transaction, exchanges the code with its PKCE + verifier, and redirects to the portal after success or with a generic + authentication error. + /auth/refresh: + post: + tags: [Authentication] + summary: Refresh the split-token session + security: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: [refresh_token] + properties: + refresh_token: + type: string + description: JavaScript-readable first half of the refresh token. + responses: + '204': { description: Token cookies replaced successfully. } + '400': { $ref: '#/components/responses/AuthError' } + '401': { $ref: '#/components/responses/AuthError' } + /auth/logout: + post: + tags: [Authentication] + summary: Clear the local session and obtain the logout URL + security: + - PortalOAuthDocumentation: [] + AccessTokenPart2: [] + responses: + '200': + description: Local cookies cleared. + content: + application/json: + schema: { $ref: '#/components/schemas/LogoutResponse' } + '401': { $ref: '#/components/responses/AuthError' } + /me/consents: get: tags: [User] summary: List current-user consents + security: + - PortalOAuthDocumentation: [portal:consents:read:self] + AccessTokenPart2: [] description: >- Proxies to `GET /api/v1/consents`, preserving supported search filters while replacing any browser-supplied `userIds` with the trusted current @@ -79,6 +156,9 @@ paths: get: tags: [User] summary: Get current-user consent details + security: + - PortalOAuthDocumentation: [portal:consents:read:self] + AccessTokenPart2: [] description: >- Fetches one consent with `details=true` and returns its embedded purpose and element display names and descriptions unchanged. Status audit @@ -99,6 +179,9 @@ paths: post: tags: [User] summary: Approve selected optional elements + security: + - PortalOAuthDocumentation: [portal:consents:write:self] + AccessTokenPart2: [] description: >- Fetches the current consent with `details=true`, validates selected optional elements against its embedded IDs and versions, and builds a @@ -135,6 +218,9 @@ paths: post: tags: [User] summary: Revoke current-user consent + security: + - PortalOAuthDocumentation: [portal:consents:write:self] + AccessTokenPart2: [] description: >- Injects the trusted current user as `actionBy`, preserves an optional `revocationReason`, and forwards the request to @@ -168,29 +254,44 @@ paths: get: tags: [Passthrough] summary: Search consents + security: + - PortalOAuthDocumentation: [portal:consents:read:any] + AccessTokenPart2: [] responses: '200': { description: Upstream response } post: tags: [Passthrough] summary: Create consent + security: + - PortalOAuthDocumentation: [portal:consents:write:any] + AccessTokenPart2: [] responses: '200': { description: Upstream response } /api/consents/attributes: get: tags: [Passthrough] summary: Search consent attributes + security: + - PortalOAuthDocumentation: [portal:consents:read:any] + AccessTokenPart2: [] responses: '200': { description: Upstream response } /api/consents/validate: post: tags: [Passthrough] summary: Validate consent + security: + - PortalOAuthDocumentation: [portal:consents:read:any] + AccessTokenPart2: [] responses: '200': { description: Upstream response } /api/consents/{consentId}: get: tags: [Passthrough] summary: Get consent + security: + - PortalOAuthDocumentation: [portal:consents:read:any] + AccessTokenPart2: [] parameters: - $ref: '#/components/parameters/ConsentID' responses: @@ -198,6 +299,9 @@ paths: put: tags: [Passthrough] summary: Update consent + security: + - PortalOAuthDocumentation: [portal:consents:write:any] + AccessTokenPart2: [] parameters: - $ref: '#/components/parameters/ConsentID' responses: @@ -206,6 +310,9 @@ paths: get: tags: [Passthrough] summary: Get consent history + security: + - PortalOAuthDocumentation: [portal:consents:read:any] + AccessTokenPart2: [] parameters: - $ref: '#/components/parameters/ConsentID' responses: @@ -214,6 +321,9 @@ paths: post: tags: [Passthrough] summary: Revoke consent + security: + - PortalOAuthDocumentation: [portal:consents:write:any] + AccessTokenPart2: [] parameters: - $ref: '#/components/parameters/ConsentID' responses: @@ -222,6 +332,9 @@ paths: get: tags: [Passthrough] summary: List consent authorizations + security: + - PortalOAuthDocumentation: [portal:consents:read:any] + AccessTokenPart2: [] parameters: - $ref: '#/components/parameters/ConsentID' responses: @@ -229,6 +342,9 @@ paths: post: tags: [Passthrough] summary: Create consent authorization + security: + - PortalOAuthDocumentation: [portal:consents:write:any] + AccessTokenPart2: [] parameters: - $ref: '#/components/parameters/ConsentID' responses: @@ -237,6 +353,9 @@ paths: get: tags: [Passthrough] summary: Get consent authorization + security: + - PortalOAuthDocumentation: [portal:consents:read:any] + AccessTokenPart2: [] parameters: - $ref: '#/components/parameters/ConsentID' - { in: path, name: authorizationId, required: true, schema: { type: string } } @@ -245,6 +364,9 @@ paths: put: tags: [Passthrough] summary: Update consent authorization + security: + - PortalOAuthDocumentation: [portal:consents:write:any] + AccessTokenPart2: [] parameters: - $ref: '#/components/parameters/ConsentID' - { in: path, name: authorizationId, required: true, schema: { type: string } } @@ -255,17 +377,26 @@ paths: get: tags: [Passthrough] summary: List consent elements + security: + - PortalOAuthDocumentation: [portal:elements:read] + AccessTokenPart2: [] responses: '200': { description: Upstream response } post: tags: [Passthrough] summary: Create consent elements + security: + - PortalOAuthDocumentation: [portal:elements:write] + AccessTokenPart2: [] responses: '200': { description: Upstream response } /api/consent-elements/{elementId}: get: tags: [Passthrough] summary: Get latest consent element + security: + - PortalOAuthDocumentation: [portal:elements:read] + AccessTokenPart2: [] parameters: - $ref: '#/components/parameters/ElementID' responses: @@ -274,6 +405,9 @@ paths: get: tags: [Passthrough] summary: List consent element versions + security: + - PortalOAuthDocumentation: [portal:elements:read] + AccessTokenPart2: [] parameters: - $ref: '#/components/parameters/ElementID' responses: @@ -281,6 +415,9 @@ paths: post: tags: [Passthrough] summary: Create consent element version + security: + - PortalOAuthDocumentation: [portal:elements:write] + AccessTokenPart2: [] parameters: - $ref: '#/components/parameters/ElementID' responses: @@ -289,6 +426,9 @@ paths: get: tags: [Passthrough] summary: Get consent element version + security: + - PortalOAuthDocumentation: [portal:elements:read] + AccessTokenPart2: [] parameters: - $ref: '#/components/parameters/ElementID' - $ref: '#/components/parameters/Version' @@ -297,6 +437,9 @@ paths: delete: tags: [Passthrough] summary: Delete consent element version + security: + - PortalOAuthDocumentation: [portal:elements:write] + AccessTokenPart2: [] parameters: - $ref: '#/components/parameters/ElementID' - $ref: '#/components/parameters/Version' @@ -307,17 +450,26 @@ paths: get: tags: [Passthrough] summary: List consent purposes + security: + - PortalOAuthDocumentation: [portal:purposes:read] + AccessTokenPart2: [] responses: '200': { description: Upstream response } post: tags: [Passthrough] summary: Create consent purpose + security: + - PortalOAuthDocumentation: [portal:purposes:write] + AccessTokenPart2: [] responses: '200': { description: Upstream response } /api/consent-purposes/{purposeId}: get: tags: [Passthrough] summary: Get latest consent purpose + security: + - PortalOAuthDocumentation: [portal:purposes:read] + AccessTokenPart2: [] parameters: - $ref: '#/components/parameters/PurposeID' responses: @@ -326,6 +478,9 @@ paths: get: tags: [Passthrough] summary: List consent purpose versions + security: + - PortalOAuthDocumentation: [portal:purposes:read] + AccessTokenPart2: [] parameters: - $ref: '#/components/parameters/PurposeID' responses: @@ -333,6 +488,9 @@ paths: post: tags: [Passthrough] summary: Create consent purpose version + security: + - PortalOAuthDocumentation: [portal:purposes:write] + AccessTokenPart2: [] parameters: - $ref: '#/components/parameters/PurposeID' responses: @@ -341,6 +499,9 @@ paths: get: tags: [Passthrough] summary: Get consent purpose version + security: + - PortalOAuthDocumentation: [portal:purposes:read] + AccessTokenPart2: [] parameters: - $ref: '#/components/parameters/PurposeID' - $ref: '#/components/parameters/Version' @@ -349,6 +510,9 @@ paths: delete: tags: [Passthrough] summary: Delete consent purpose version + security: + - PortalOAuthDocumentation: [portal:purposes:write] + AccessTokenPart2: [] parameters: - $ref: '#/components/parameters/PurposeID' - $ref: '#/components/parameters/Version' @@ -356,12 +520,45 @@ paths: '204': { description: Version deleted } components: + securitySchemes: + PortalOAuthDocumentation: + type: oauth2 + x-documentation-only: true + description: >- + Documentation model for the OAuth2 scopes enforced by the BFF. The URLs + are deployment-specific placeholders. Note that the Swagger UI cannot execute the + actual split-token flow. The Authorization header contains only the + JavaScript-readable first half of the access token, and the BFF combines + it with AccessTokenPart2 before validation. + flows: + authorizationCode: + authorizationUrl: https://idp.example.com/oauth2/authorize + tokenUrl: https://idp.example.com/oauth2/token + scopes: + 'portal:consents:read:self': Read the current user's consents. + 'portal:consents:write:self': Approve or revoke the current user's consents. + 'portal:consents:read:any': Read consents through passthrough APIs. + 'portal:consents:write:any': Modify consents through passthrough APIs. + 'portal:elements:read': Read consent elements. + 'portal:elements:write': Modify consent elements. + 'portal:purposes:read': Read consent purposes. + 'portal:purposes:write': Modify consent purposes. + AccessTokenPart2: + type: apiKey + in: cookie + name: portal-at-p2 + description: Configurable HttpOnly second-half access-token cookie. parameters: ConsentID: { in: path, name: consentId, required: true, schema: { type: string } } PurposeID: { in: path, name: purposeId, required: true, schema: { type: string } } ElementID: { in: path, name: elementId, required: true, schema: { type: string } } Version: { in: path, name: version, required: true, schema: { type: string, pattern: '^v[1-9][0-9]*$' } } schemas: + LogoutResponse: + type: object + required: [logoutUrl] + properties: + logoutUrl: { type: string, format: uri } ConsentSearchResponse: type: object required: [data, metadata] @@ -450,6 +647,11 @@ components: code: { type: string, example: UPSTREAM_UNAVAILABLE } message: { type: string, example: upstream unavailable } responses: + AuthError: + description: Authentication request rejected. + content: + application/json: + schema: { $ref: '#/components/schemas/ProxyError' } ProxyError: description: BFF or upstream proxy error content: diff --git a/portal/backend/tests/integration/auth_integration_test.go b/portal/backend/tests/integration/auth_integration_test.go new file mode 100644 index 00000000..894d517c --- /dev/null +++ b/portal/backend/tests/integration/auth_integration_test.go @@ -0,0 +1,459 @@ +/* + * 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 integration + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + + "github.com/wso2/openfgc/portal/backend/internal/system/auth" + "github.com/wso2/openfgc/portal/backend/internal/system/config" +) + +type authIntegrationProvider struct { + t *testing.T + server *httptest.Server + key *rsa.PrivateKey + keyID string + scopes string + + mu sync.Mutex + refreshCalls int +} + +func newAuthIntegrationProvider(t *testing.T, scopes string) *authIntegrationProvider { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + provider := &authIntegrationProvider{t: t, key: key, keyID: "integration-key", scopes: scopes} + provider.server = httptest.NewServer(http.HandlerFunc(provider.serveHTTP)) + t.Cleanup(provider.server.Close) + return provider +} + +func (p *authIntegrationProvider) serveHTTP(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/openid-configuration": + writeAuthIntegrationJSON(w, map[string]any{ + "issuer": p.server.URL, "authorization_endpoint": p.server.URL + "/authorize", + "token_endpoint": p.server.URL + "/token", "jwks_uri": p.server.URL + "/jwks", + "end_session_endpoint": p.server.URL + "/logout", + "id_token_signing_alg_values_supported": []string{"RS256"}, + }) + case "/jwks": + writeAuthIntegrationJSON(w, jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{ + Key: &p.key.PublicKey, KeyID: p.keyID, Algorithm: "RS256", Use: "sig", + }}}) + case "/token": + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + if clientID, secret, ok := r.BasicAuth(); !ok || clientID != "integration-client" || secret != "integration-secret" { + http.Error(w, "invalid client", http.StatusUnauthorized) + return + } + if r.Form.Get("grant_type") != "refresh_token" && r.Form.Get("code_verifier") == "" { + http.Error(w, "missing PKCE verifier", http.StatusBadRequest) + return + } + refreshToken := "refresh-token-initial" + if r.Form.Get("grant_type") == "refresh_token" { + p.mu.Lock() + p.refreshCalls++ + p.mu.Unlock() + refreshToken = "refresh-token-rotated" + } + writeAuthIntegrationJSON(w, p.tokenResponse(refreshToken)) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +func (p *authIntegrationProvider) tokenResponse(refreshToken string) map[string]any { + now := time.Now() + accessToken := signAuthIntegrationToken(p.t, p.key, p.keyID, map[string]any{ + "iss": p.server.URL, "sub": "integration-user", "aud": "integration-api", + "exp": now.Add(time.Hour).Unix(), "iat": now.Unix(), "nbf": now.Add(-time.Minute).Unix(), + "org_id": "integration-org", "scope": p.scopes, + }) + idToken := signAuthIntegrationToken(p.t, p.key, p.keyID, map[string]any{ + "iss": p.server.URL, "sub": "integration-user", "aud": "integration-client", + "exp": now.Add(time.Hour).Unix(), "iat": now.Unix(), + }) + return map[string]any{ + "access_token": accessToken, "refresh_token": refreshToken, "id_token": idToken, + "token_type": "Bearer", "expires_in": 3600, + } +} + +type authIntegrationUpstreamCapture struct { + mu sync.Mutex + orgID string + userIDs string +} + +func (c *authIntegrationUpstreamCapture) handler(w http.ResponseWriter, r *http.Request) { + c.mu.Lock() + c.orgID = r.Header.Get("org-id") + c.userIDs = r.URL.Query().Get("userIds") + c.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + writeAuthIntegrationJSON(w, map[string]any{ + "data": []any{}, "metadata": map[string]int{"total": 0, "offset": 0, "count": 0, "limit": 10}, + }) +} + +type authIntegrationFixture struct { + provider *authIntegrationProvider + upstream *httptest.Server + bff *httptest.Server + client *http.Client + capture *authIntegrationUpstreamCapture + cfg config.Config +} + +func newAuthIntegrationFixture(t *testing.T, tokenScopes string) *authIntegrationFixture { + t.Helper() + provider := newAuthIntegrationProvider(t, tokenScopes) + capture := &authIntegrationUpstreamCapture{} + upstream := httptest.NewServer(http.HandlerFunc(capture.handler)) + t.Cleanup(upstream.Close) + + loaded, err := config.Load() + if err != nil { + t.Fatalf("load config: %v", err) + } + cfg := *loaded + cfg.Auth = config.AuthConfig{ + Enabled: true, IssuerURL: provider.server.URL, + ClientID: "integration-client", ClientSecret: "integration-secret", + PortalURL: "http://portal.example/", RedirectURI: "http://bff.example/auth/callback", + PostLogoutRedirectURI: "http://portal.example/signed-out", + Scopes: []string{"openid", "profile"}, ResourceAudience: "integration-api", + AllowedSigningAlgorithms: []string{"RS256"}, HTTPTimeout: 5 * time.Second, + RefreshTimeout: 5 * time.Second, ClockSkew: 30 * time.Second, + ScopeClaim: "scope", OrgIDClaim: "org_id", + AccessTokenPart1Cookie: "integration-at-p1", AccessTokenPart2Cookie: "integration-at-p2", + RefreshTokenPart1Cookie: "integration-rt-p1", RefreshTokenPart2Cookie: "integration-rt-p2", + IDTokenPart1Cookie: "integration-id-p1", IDTokenPart2Cookie: "integration-id-p2", + OAuthStateCookie: "integration-oauth-state", PKCEVerifierCookie: "integration-pkce-verifier", + CookieSecure: false, CookieSameSite: "Lax", LoginTransactionMaxAgeSeconds: 600, + RefreshCookieMaxAgeSeconds: 3600, + MaxTokenPartBytes: 3800, MaxReconstructedTokenBytes: 7600, + } + cfg.Proxy.OpenFGCAPIURL = upstream.URL + cfg.Proxy.PlaceholderModeEnabled = false + cfg.Proxy.PlaceholderUserID = "" + cfg.Proxy.PlaceholderOrgID = "" + cfg.CORS = config.CORSConfig{ + AllowedOrigins: []string{"http://frontend.example"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Content-Type", "Authorization", "X-Correlation-ID"}, + AllowCredentials: true, + } + handler, err := newIntegrationHandler(cfg) + if err != nil { + t.Fatalf("assemble BFF: %v", err) + } + bff := httptest.NewServer(handler) + t.Cleanup(bff.Close) + client := &http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }} + return &authIntegrationFixture{provider: provider, upstream: upstream, bff: bff, client: client, capture: capture, cfg: cfg} +} + +func (f *authIntegrationFixture) login(t *testing.T) map[string]*http.Cookie { + t.Helper() + loginResponse, err := f.client.Get(f.bff.URL + "/auth/login") + if err != nil { + t.Fatalf("login request: %v", err) + } + _ = loginResponse.Body.Close() + if loginResponse.StatusCode != http.StatusFound { + t.Fatalf("login returned %d", loginResponse.StatusCode) + } + authorizationURL, err := url.Parse(loginResponse.Header.Get("Location")) + if err != nil || authorizationURL.Host != strings.TrimPrefix(f.provider.server.URL, "http://") { + t.Fatalf("unexpected authorization redirect: %q", loginResponse.Header.Get("Location")) + } + if authorizationURL.Query().Get("client_id") != f.cfg.Auth.ClientID || authorizationURL.Query().Get("redirect_uri") != f.cfg.Auth.RedirectURI { + t.Fatalf("authorization redirect has incorrect client parameters: %s", authorizationURL.RawQuery) + } + state := authorizationURL.Query().Get("state") + if state == "" || authorizationURL.Query().Get("code_challenge") == "" || authorizationURL.Query().Get("code_challenge_method") != "S256" { + t.Fatalf("authorization redirect is missing state or S256 PKCE: %s", authorizationURL.RawQuery) + } + loginCookies := authIntegrationCookiesByName(loginResponse.Cookies()) + if loginCookies[f.cfg.Auth.OAuthStateCookie] == nil || loginCookies[f.cfg.Auth.PKCEVerifierCookie] == nil { + t.Fatal("login did not issue transaction cookies") + } + + callbackRequest, _ := http.NewRequest(http.MethodGet, f.bff.URL+"/auth/callback?code=integration-code&state="+url.QueryEscape(state), nil) + callbackRequest.AddCookie(loginCookies[f.cfg.Auth.OAuthStateCookie]) + callbackRequest.AddCookie(loginCookies[f.cfg.Auth.PKCEVerifierCookie]) + callbackResponse, err := f.client.Do(callbackRequest) + if err != nil { + t.Fatalf("callback request: %v", err) + } + _ = callbackResponse.Body.Close() + if callbackResponse.StatusCode != http.StatusFound || callbackResponse.Header.Get("Location") != f.cfg.Auth.PortalURL { + t.Fatalf("unexpected callback response: %d %q", callbackResponse.StatusCode, callbackResponse.Header.Get("Location")) + } + replayResponse, err := f.client.Get(f.bff.URL + "/auth/callback?code=integration-code&state=" + url.QueryEscape(state)) + if err != nil { + t.Fatalf("replayed callback request: %v", err) + } + _ = replayResponse.Body.Close() + replayLocation, parseErr := url.Parse(replayResponse.Header.Get("Location")) + if replayResponse.StatusCode != http.StatusFound || parseErr != nil || replayLocation.Query().Get("auth_error") != "login_failed" { + t.Fatalf("replayed callback was not rejected: %d %q", replayResponse.StatusCode, replayResponse.Header.Get("Location")) + } + cookies := authIntegrationCookiesByName(callbackResponse.Cookies()) + for _, name := range []string{ + f.cfg.Auth.AccessTokenPart1Cookie, f.cfg.Auth.AccessTokenPart2Cookie, + f.cfg.Auth.RefreshTokenPart1Cookie, f.cfg.Auth.RefreshTokenPart2Cookie, + f.cfg.Auth.IDTokenPart1Cookie, f.cfg.Auth.IDTokenPart2Cookie, + } { + if cookies[name] == nil { + t.Fatalf("callback did not issue %s", name) + } + } + return cookies +} + +func TestOIDCAuthLifecycleThroughAssembledBFF(t *testing.T) { + fixture := newAuthIntegrationFixture(t, strings.Join(auth.AllPortalScopes, " ")) + cookies := fixture.login(t) + + apiRequest, _ := http.NewRequest(http.MethodGet, fixture.bff.URL+"/api/consents?limit=10", nil) + addAuthIntegrationAccess(apiRequest, fixture.cfg.Auth, cookies) + apiResponse, err := fixture.client.Do(apiRequest) + if err != nil { + t.Fatalf("protected API request: %v", err) + } + _ = apiResponse.Body.Close() + if apiResponse.StatusCode != http.StatusOK { + t.Fatalf("protected API returned %d", apiResponse.StatusCode) + } + fixture.capture.mu.Lock() + if fixture.capture.orgID != "integration-org" { + t.Errorf("upstream received org-id %q", fixture.capture.orgID) + } + fixture.capture.mu.Unlock() + + meRequest, _ := http.NewRequest(http.MethodGet, fixture.bff.URL+"/me/consents?userIds=attacker", nil) + addAuthIntegrationAccess(meRequest, fixture.cfg.Auth, cookies) + meResponse, err := fixture.client.Do(meRequest) + if err != nil { + t.Fatalf("protected me request: %v", err) + } + _ = meResponse.Body.Close() + if meResponse.StatusCode != http.StatusOK { + t.Fatalf("protected me route returned %d", meResponse.StatusCode) + } + fixture.capture.mu.Lock() + if fixture.capture.userIDs != "integration-user" { + t.Errorf("upstream received userIds %q", fixture.capture.userIDs) + } + fixture.capture.mu.Unlock() + + refreshBody := url.Values{"refresh_token": {cookies[fixture.cfg.Auth.RefreshTokenPart1Cookie].Value}}.Encode() + refreshRequest, _ := http.NewRequest(http.MethodPost, fixture.bff.URL+"/auth/refresh", strings.NewReader(refreshBody)) + refreshRequest.Header.Set("Content-Type", "application/x-www-form-urlencoded") + refreshRequest.AddCookie(cookies[fixture.cfg.Auth.RefreshTokenPart2Cookie]) + refreshResponse, err := fixture.client.Do(refreshRequest) + if err != nil { + t.Fatalf("refresh request: %v", err) + } + _ = refreshResponse.Body.Close() + if refreshResponse.StatusCode != http.StatusNoContent { + t.Fatalf("refresh returned %d", refreshResponse.StatusCode) + } + mergeAuthIntegrationCookies(cookies, refreshResponse.Cookies()) + fixture.provider.mu.Lock() + if fixture.provider.refreshCalls != 1 { + t.Errorf("expected one IdP refresh, got %d", fixture.provider.refreshCalls) + } + fixture.provider.mu.Unlock() + + logoutRequest, _ := http.NewRequest(http.MethodPost, fixture.bff.URL+"/auth/logout", nil) + addAuthIntegrationAccess(logoutRequest, fixture.cfg.Auth, cookies) + logoutRequest.AddCookie(cookies[fixture.cfg.Auth.IDTokenPart1Cookie]) + logoutRequest.AddCookie(cookies[fixture.cfg.Auth.IDTokenPart2Cookie]) + logoutResponse, err := fixture.client.Do(logoutRequest) + if err != nil { + t.Fatalf("logout request: %v", err) + } + defer func() { _ = logoutResponse.Body.Close() }() + if logoutResponse.StatusCode != http.StatusOK { + t.Fatalf("logout returned %d", logoutResponse.StatusCode) + } + var logoutPayload map[string]string + if err := json.NewDecoder(logoutResponse.Body).Decode(&logoutPayload); err != nil { + t.Fatalf("decode logout response: %v", err) + } + logoutURL, err := url.Parse(logoutPayload["logoutUrl"]) + if err != nil || logoutURL.Query().Get("id_token_hint") == "" || logoutURL.Query().Get("post_logout_redirect_uri") != fixture.cfg.Auth.PostLogoutRedirectURI { + t.Fatalf("unexpected logout URL: %q", logoutPayload["logoutUrl"]) + } + cleared := 0 + for _, cookie := range logoutResponse.Cookies() { + if cookie.MaxAge == -1 { + cleared++ + } + } + if cleared != 6 { + t.Fatalf("logout cleared %d cookies, want 6", cleared) + } +} + +func TestSplitAuthenticationFailuresThroughAssembledBFF(t *testing.T) { + fixture := newAuthIntegrationFixture(t, strings.Join(auth.AllPortalScopes, " ")) + cookies := fixture.login(t) + part1 := cookies[fixture.cfg.Auth.AccessTokenPart1Cookie].Value + part2 := cookies[fixture.cfg.Auth.AccessTokenPart2Cookie].Value + fullToken := part1 + part2 + + tests := []struct { + name string + authorization []string + cookieHeader string + }{ + {name: "missing readable half", cookieHeader: fixture.cfg.Auth.AccessTokenPart2Cookie + "=" + part2}, + {name: "missing secure half", authorization: []string{"Bearer " + part1}}, + {name: "complete token injection", authorization: []string{"Bearer " + fullToken}, cookieHeader: fixture.cfg.Auth.AccessTokenPart2Cookie + "=" + part2}, + {name: "duplicate bearer", authorization: []string{"Bearer " + part1, "Bearer " + part1}, cookieHeader: fixture.cfg.Auth.AccessTokenPart2Cookie + "=" + part2}, + {name: "duplicate secure cookie", authorization: []string{"Bearer " + part1}, cookieHeader: fixture.cfg.Auth.AccessTokenPart2Cookie + "=" + part2 + "; " + fixture.cfg.Auth.AccessTokenPart2Cookie + "=" + part2}, + {name: "tampered readable half", authorization: []string{"Bearer x" + part1[1:]}, cookieHeader: fixture.cfg.Auth.AccessTokenPart2Cookie + "=" + part2}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request, _ := http.NewRequest(http.MethodGet, fixture.bff.URL+"/api/consents", nil) + for _, value := range test.authorization { + request.Header.Add("Authorization", value) + } + if test.cookieHeader != "" { + request.Header.Set("Cookie", test.cookieHeader) + } + response, err := fixture.client.Do(request) + if err != nil { + t.Fatalf("request: %v", err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusUnauthorized || response.Header.Get("WWW-Authenticate") != "Bearer" { + t.Fatalf("got status %d and challenge %q", response.StatusCode, response.Header.Get("WWW-Authenticate")) + } + }) + } +} + +func TestScopeAndCORSAuthorizationThroughAssembledBFF(t *testing.T) { + fixture := newAuthIntegrationFixture(t, auth.ScopeConsentsReadSelf) + cookies := fixture.login(t) + + apiRequest, _ := http.NewRequest(http.MethodGet, fixture.bff.URL+"/api/consents", nil) + addAuthIntegrationAccess(apiRequest, fixture.cfg.Auth, cookies) + apiResponse, err := fixture.client.Do(apiRequest) + if err != nil { + t.Fatal(err) + } + _ = apiResponse.Body.Close() + if apiResponse.StatusCode != http.StatusForbidden { + t.Fatalf("self scope authorized /api route with status %d", apiResponse.StatusCode) + } + + meRequest, _ := http.NewRequest(http.MethodGet, fixture.bff.URL+"/me/consents", nil) + addAuthIntegrationAccess(meRequest, fixture.cfg.Auth, cookies) + meResponse, err := fixture.client.Do(meRequest) + if err != nil { + t.Fatal(err) + } + _ = meResponse.Body.Close() + if meResponse.StatusCode != http.StatusOK { + t.Fatalf("self scope failed /me route with status %d", meResponse.StatusCode) + } + + preflight, _ := http.NewRequest(http.MethodOptions, fixture.bff.URL+"/api/consents", nil) + preflight.Header.Set("Origin", "http://frontend.example") + preflight.Header.Set("Access-Control-Request-Method", http.MethodGet) + preflight.Header.Set("Access-Control-Request-Headers", "Authorization") + preflightResponse, err := fixture.client.Do(preflight) + if err != nil { + t.Fatal(err) + } + _ = preflightResponse.Body.Close() + if preflightResponse.StatusCode != http.StatusNoContent || + preflightResponse.Header.Get("Access-Control-Allow-Origin") != "http://frontend.example" || + preflightResponse.Header.Get("Access-Control-Allow-Credentials") != "true" || + !strings.Contains(preflightResponse.Header.Get("Access-Control-Allow-Headers"), "Authorization") { + t.Fatalf("unexpected credentialed preflight response: %d %#v", preflightResponse.StatusCode, preflightResponse.Header) + } +} + +func addAuthIntegrationAccess(request *http.Request, cfg config.AuthConfig, cookies map[string]*http.Cookie) { + request.Header.Set("Authorization", "Bearer "+cookies[cfg.AccessTokenPart1Cookie].Value) + request.AddCookie(cookies[cfg.AccessTokenPart2Cookie]) +} + +func authIntegrationCookiesByName(cookies []*http.Cookie) map[string]*http.Cookie { + result := make(map[string]*http.Cookie, len(cookies)) + for _, cookie := range cookies { + result[cookie.Name] = cookie + } + return result +} + +func mergeAuthIntegrationCookies(destination map[string]*http.Cookie, cookies []*http.Cookie) { + for _, cookie := range cookies { + if cookie.MaxAge >= 0 { + destination[cookie.Name] = cookie + } + } +} + +func signAuthIntegrationToken(t *testing.T, key *rsa.PrivateKey, keyID string, claims map[string]any) string { + t.Helper() + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key}, (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", keyID)) + if err != nil { + t.Fatal(err) + } + serialized, err := jwt.Signed(signer).Claims(claims).Serialize() + if err != nil { + t.Fatal(err) + } + return serialized +} + +func writeAuthIntegrationJSON(w http.ResponseWriter, value any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(value) +} diff --git a/portal/backend/tests/integration/health_test.go b/portal/backend/tests/integration/health_integration_test.go similarity index 100% rename from portal/backend/tests/integration/health_test.go rename to portal/backend/tests/integration/health_integration_test.go diff --git a/portal/backend/tests/integration/server_test.go b/portal/backend/tests/integration/integration_test_helpers_test.go similarity index 69% rename from portal/backend/tests/integration/server_test.go rename to portal/backend/tests/integration/integration_test_helpers_test.go index 1301461a..2978cbad 100644 --- a/portal/backend/tests/integration/server_test.go +++ b/portal/backend/tests/integration/integration_test_helpers_test.go @@ -1,10 +1,12 @@ package integration import ( + "context" "net/http" "github.com/wso2/openfgc/portal/backend/internal/me" "github.com/wso2/openfgc/portal/backend/internal/proxy" + "github.com/wso2/openfgc/portal/backend/internal/system/auth" "github.com/wso2/openfgc/portal/backend/internal/system/config" "github.com/wso2/openfgc/portal/backend/internal/system/healthcheck" systemlog "github.com/wso2/openfgc/portal/backend/internal/system/log" @@ -19,10 +21,16 @@ func newIntegrationHandler(cfg config.Config) (http.Handler, error) { mux.HandleFunc("GET /health/readiness", healthHandler.Readiness) mux.HandleFunc("GET /health", healthHandler.Liveness) - if err := proxy.Initialize(mux, cfg.Proxy); err != nil { + log := systemlog.New(cfg.Log.Level) + authManager, err := auth.NewManager(context.Background(), cfg.Auth, cfg.Proxy, log) + if err != nil { return nil, err } - if err := me.Initialize(mux, cfg); err != nil { + authManager.RegisterRoutes(mux) + if err := proxy.Initialize(mux, cfg.Proxy, authManager); err != nil { + return nil, err + } + if err := me.Initialize(mux, cfg, authManager); err != nil { return nil, err } @@ -33,5 +41,5 @@ func newIntegrationHandler(cfg config.Config) (http.Handler, error) { AllowCredentials: cfg.CORS.AllowCredentials, }) - return middleware.CorrelationID(systemlog.New(cfg.Log.Level), withCORS), nil + return middleware.CorrelationID(log, withCORS), nil } diff --git a/portal/backend/tests/integration/proxy_phase2_test.go b/portal/backend/tests/integration/proxy_integration_test.go similarity index 77% rename from portal/backend/tests/integration/proxy_phase2_test.go rename to portal/backend/tests/integration/proxy_integration_test.go index fdc8b045..c746dacb 100644 --- a/portal/backend/tests/integration/proxy_phase2_test.go +++ b/portal/backend/tests/integration/proxy_integration_test.go @@ -44,7 +44,6 @@ func newPortalServer(t *testing.T, upstreamURL string, configure func(*config.Co cfg.Proxy.PlaceholderModeEnabled = true cfg.Proxy.PlaceholderUserID = "user@example.com" cfg.Proxy.PlaceholderOrgID = "ORG-001" - cfg.Proxy.PlaceholderGroupID = "GROUP-001" if configure != nil { configure(cfg) } @@ -88,7 +87,7 @@ func TestAPIPassthroughRewriteAndHeaderSafety(t *testing.T) { if resp.StatusCode != http.StatusOK || gotPath != "/api/v1/consents" || gotQuery != "limit=10&offset=2" { t.Fatalf("unexpected passthrough result: status=%d path=%s query=%s", resp.StatusCode, gotPath, gotQuery) } - if gotOrg != "ORG-001" || gotGroup != "GROUP-001" || gotLegacyClient != "" { + if gotOrg != "ORG-001" || gotGroup != "" || gotLegacyClient != "" { t.Fatalf("unexpected trusted headers: org=%q group=%q legacy=%q", gotOrg, gotGroup, gotLegacyClient) } } @@ -125,7 +124,7 @@ func TestConsentDetailsUseSingleDetailedConsentRequest(t *testing.T) { switch r.URL.Path { case "/api/v1/consents/" + consentID: gotDetails = r.URL.Query().Get("details") - _, _ = w.Write([]byte(`{"id":"` + consentID + `","groupId":"GROUP-001","type":"accounts","status":"ACTIVE","createdTime":1702800000000,"updatedTime":1702800001000,"attributes":{},"authorizations":[],"purposes":[{"purposeId":"purpose-profile","name":"profile_access","version":"v2","displayName":"Profile access","description":"Profile purpose","elements":[{"elementId":"element-email","name":"email","namespace":"profile","version":"v3","displayName":"Email address","description":"User email address","mandatory":true,"approved":true}]}]}`)) + _, _ = w.Write([]byte(`{"id":"` + consentID + `","groupId":"GROUP-001","type":"accounts","status":"ACTIVE","createdTime":1702800000000,"updatedTime":1702800001000,"attributes":{},"authorizations":[{"userId":"user@example.com","type":"authorisation","status":"APPROVED"}],"purposes":[{"purposeId":"purpose-profile","name":"profile_access","version":"v2","displayName":"Profile access","description":"Profile purpose","elements":[{"elementId":"element-email","name":"email","namespace":"profile","version":"v3","displayName":"Email address","description":"User email address","mandatory":true,"approved":true}]}]}`)) default: w.WriteHeader(http.StatusNotFound) } @@ -192,7 +191,7 @@ func TestApprovalBuildsVersionedUpdateAndTrustedGroupHeader(t *testing.T) { switch { case r.Method == http.MethodGet && r.URL.Path == "/api/v1/consents/"+consentID: consentDetails = r.URL.Query().Get("details") - _, _ = w.Write([]byte(`{"id":"` + consentID + `","groupId":"GROUP-BOUND","type":"accounts","status":"CREATED","frequency":0,"expirationTime":0,"recurringIndicator":false,"dataAccessValidityDuration":86400,"attributes":{"region":"APAC"},"authorizations":[{"id":"auth-1","userId":"existing@example.com","type":"authorisation","status":"APPROVED","updatedTime":1702800000000,"resources":{}}],"purposes":[{"purposeId":"purpose-profile","name":"profile_access","version":"v2","elements":[{"elementId":"element-first","name":"first_name","namespace":"profile","version":"v1","mandatory":true,"approved":false},{"elementId":"element-last","name":"last_name","namespace":"profile","version":"v2","mandatory":false,"approved":false},{"elementId":"element-email","name":"email","namespace":"profile","version":"v3","mandatory":false,"approved":true}]}]}`)) + _, _ = w.Write([]byte(`{"id":"` + consentID + `","groupId":"GROUP-BOUND","type":"accounts","status":"CREATED","frequency":0,"expirationTime":0,"recurringIndicator":false,"dataAccessValidityDuration":86400,"attributes":{"region":"APAC"},"authorizations":[{"id":"auth-1","userId":"existing@example.com","type":"authorisation","status":"APPROVED","updatedTime":1702800000000,"resources":{}},{"id":"auth-2","userId":"user@example.com","type":"authorisation","status":"CREATED","updatedTime":1702800000000,"resources":{}}],"purposes":[{"purposeId":"purpose-profile","name":"profile_access","version":"v2","elements":[{"elementId":"element-first","name":"first_name","namespace":"profile","version":"v1","mandatory":true,"approved":false},{"elementId":"element-last","name":"last_name","namespace":"profile","version":"v2","mandatory":false,"approved":false},{"elementId":"element-email","name":"email","namespace":"profile","version":"v3","mandatory":false,"approved":true}]}]}`)) case r.Method == http.MethodPut && r.URL.Path == "/api/v1/consents/"+consentID: updateGroup = r.Header.Get("group-id") _ = json.NewDecoder(r.Body).Decode(&updateBody) @@ -202,9 +201,7 @@ func TestApprovalBuildsVersionedUpdateAndTrustedGroupHeader(t *testing.T) { } })) defer upstream.Close() - bff := newPortalServer(t, upstream.URL, func(cfg *config.Config) { - cfg.Proxy.PlaceholderGroupID = "GROUP-PLACEHOLDER" - }) + bff := newPortalServer(t, upstream.URL, nil) defer bff.Close() selection := `[{"purposeId":"purpose-profile","purposeVersion":"v2","elementId":"element-last","elementVersion":"v2"}]` @@ -269,7 +266,7 @@ func approvalReadOnlyUpstream(t *testing.T) *httptest.Server { if r.URL.Query().Get("details") != "true" { t.Errorf("expected details=true, got %v", r.URL.Query()) } - _, _ = w.Write([]byte(`{"id":"` + consentID + `","groupId":"GROUP-001","type":"accounts","status":"CREATED","attributes":{},"authorizations":[],"purposes":[{"purposeId":"purpose-profile","name":"profile_access","version":"v2","elements":[{"elementId":"element-first","name":"first_name","namespace":"profile","version":"v1","mandatory":true,"approved":false}]}]}`)) + _, _ = w.Write([]byte(`{"id":"` + consentID + `","groupId":"GROUP-001","type":"accounts","status":"CREATED","attributes":{},"authorizations":[{"userId":"user@example.com","type":"authorisation","status":"CREATED"}],"purposes":[{"purposeId":"purpose-profile","name":"profile_access","version":"v2","elements":[{"elementId":"element-first","name":"first_name","namespace":"profile","version":"v1","mandatory":true,"approved":false}]}]}`)) default: w.WriteHeader(http.StatusNotFound) } @@ -280,6 +277,10 @@ func TestRevokeUsesPostAndInjectsActionBy(t *testing.T) { var method, path string var body map[string]any upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v1/consents/"+consentID { + _, _ = w.Write([]byte(`{"id":"` + consentID + `","groupId":"GROUP-001","authorizations":[{"userId":"user@example.com"}]}`)) + return + } method, path = r.Method, r.URL.Path _ = json.NewDecoder(r.Body).Decode(&body) w.WriteHeader(http.StatusOK) @@ -372,6 +373,9 @@ func TestProxyErrorAndBodyLimits(t *testing.T) { defer upstream.Close() cfg, _ := config.Load() cfg.Proxy.OpenFGCAPIURL = upstream.URL + cfg.Proxy.PlaceholderModeEnabled = true + cfg.Proxy.PlaceholderUserID = "user@example.com" + cfg.Proxy.PlaceholderOrgID = "ORG-001" h, _ := newIntegrationHandler(*cfg) req := httptest.NewRequest(http.MethodPost, "/api/consents", nil) req.Body = failingReadCloser{} @@ -394,7 +398,6 @@ func TestMeEndpointsReturn503WhenPlaceholderModeDisabled(t *testing.T) { cfg.Proxy.PlaceholderModeEnabled = false cfg.Proxy.PlaceholderUserID = "" cfg.Proxy.PlaceholderOrgID = "" - cfg.Proxy.PlaceholderGroupID = "" }) defer bff.Close() @@ -411,8 +414,94 @@ func TestMeEndpointsReturn503WhenPlaceholderModeDisabled(t *testing.T) { t.Fatalf("request failed: %v", err) } _ = resp.Body.Close() - if resp.StatusCode != http.StatusServiceUnavailable || called { - t.Fatalf("%s %s: expected local 503, got status=%d called=%v", tc.method, tc.path, resp.StatusCode, called) + if resp.StatusCode != http.StatusUnauthorized || called { + t.Fatalf("%s %s: expected local 401, got status=%d called=%v", tc.method, tc.path, resp.StatusCode, called) + } + } +} +func TestConsentDetailsConcealsOwnershipFailure(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/consents/"+consentID { + _, _ = w.Write([]byte(`{"id":"` + consentID + `","groupId":"GROUP-001","authorizations":[{"userId":"another-user@example.com"}],"purposes":[]}`)) + return } + w.WriteHeader(http.StatusInternalServerError) + })) + defer upstream.Close() + bff := newPortalServer(t, upstream.URL, nil) + defer bff.Close() + + resp, err := http.Get(bff.URL + "/me/consents/" + consentID) + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected concealed 404, got %d", resp.StatusCode) + } +} + +func TestMeEndpointsRejectInvalidConsentID(t *testing.T) { + upstreamCalled := false + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + upstreamCalled = true + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + bff := newPortalServer(t, upstream.URL, nil) + defer bff.Close() + + testCases := []struct { + name string + method string + path string + body string + }{ + {name: "get by id", method: http.MethodGet, path: "/me/consents/not-a-uuid"}, + {name: "approve", method: http.MethodPost, path: "/me/consents/not-a-uuid/approve", body: "[]"}, + {name: "revoke", method: http.MethodPost, path: "/me/consents/not-a-uuid/revoke", body: "{}"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + upstreamCalled = false + + var body io.Reader + if tc.body != "" { + body = strings.NewReader(tc.body) + } + + req, err := http.NewRequest(tc.method, bff.URL+tc.path, body) + if err != nil { + t.Fatalf("request creation failed: %v", err) + } + if tc.body != "" { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", resp.StatusCode) + } + if upstreamCalled { + t.Fatal("expected request to be rejected before upstream call") + } + + var payload map[string]any + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatalf("expected json error payload: %v", err) + } + if payload["code"] != "INVALID_CONSENT_ID" { + t.Fatalf("expected INVALID_CONSENT_ID, got %v", payload["code"]) + } + }) } } diff --git a/portal/backend/tests/integration/proxy_invalid_consent_id_test.go b/portal/backend/tests/integration/proxy_invalid_consent_id_test.go deleted file mode 100644 index 5065c0ff..00000000 --- a/portal/backend/tests/integration/proxy_invalid_consent_id_test.go +++ /dev/null @@ -1,93 +0,0 @@ -/* - * 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 integration - -import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestMeEndpointsRejectInvalidConsentID(t *testing.T) { - upstreamCalled := false - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - upstreamCalled = true - w.WriteHeader(http.StatusOK) - })) - defer upstream.Close() - - bff := newPortalServer(t, upstream.URL, nil) - defer bff.Close() - - testCases := []struct { - name string - method string - path string - body string - }{ - {name: "get by id", method: http.MethodGet, path: "/me/consents/not-a-uuid"}, - {name: "approve", method: http.MethodPost, path: "/me/consents/not-a-uuid/approve", body: "[]"}, - {name: "revoke", method: http.MethodPost, path: "/me/consents/not-a-uuid/revoke", body: "{}"}, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - upstreamCalled = false - - var body io.Reader - if tc.body != "" { - body = strings.NewReader(tc.body) - } - - req, err := http.NewRequest(tc.method, bff.URL+tc.path, body) - if err != nil { - t.Fatalf("request creation failed: %v", err) - } - if tc.body != "" { - req.Header.Set("Content-Type", "application/json") - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("request failed: %v", err) - } - defer func() { - _ = resp.Body.Close() - }() - - if resp.StatusCode != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", resp.StatusCode) - } - if upstreamCalled { - t.Fatal("expected request to be rejected before upstream call") - } - - var payload map[string]any - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - t.Fatalf("expected json error payload: %v", err) - } - if payload["code"] != "INVALID_CONSENT_ID" { - t.Fatalf("expected INVALID_CONSENT_ID, got %v", payload["code"]) - } - }) - } -} diff --git a/portal/bff-implementation-phases.md b/portal/bff-implementation-phases.md index 8697a5d5..c3b4d76e 100644 --- a/portal/bff-implementation-phases.md +++ b/portal/bff-implementation-phases.md @@ -42,11 +42,11 @@ Exit criteria: **Phase 3: Authentication and session implementation (depends on Phase 2)** Goal: replace placeholders with real identity from auth context. -1. Implement login, callback, me, refresh, and logout flows. -2. Implement split-cookie security model and CSRF protections. +1. Implement login, callback, frontend ID-token profile decoding, refresh, and logout flows. Do not add `GET /auth/me`. +2. Implement the split-cookie security model; this stage uses possession of both split parts instead of a separate CSRF token. 3. Add auth/session middleware and context propagation. 4. Replace config-based identity injection with validated principal-derived values. -5. Add end-to-end auth + proxy integration tests. +5. Add mock-IdP auth lifecycle tests and authenticated proxy integration tests. Exit criteria: 1. Full authenticated request lifecycle works end to end. @@ -64,4 +64,4 @@ Goal: finalize operational safety and production readiness. Exit criteria: 1. All tests and quality gates are green. 2. Security and operational checks are complete. -3. Release artifact is ready. \ No newline at end of file +3. Release artifact is ready. diff --git a/portal/bff-implementation-plan.md b/portal/bff-implementation-plan.md index aa01542e..6784d430 100644 --- a/portal/bff-implementation-plan.md +++ b/portal/bff-implementation-plan.md @@ -1,1039 +1,736 @@ -# Stateless BFF Implementation Plan -**OAuth/OIDC · Portal · OpenFGC Integration (IdP-Agnostic)** +# Portal BFF Implementation Plan +**Confidential OIDC BFF · Split-Token Cookies · JWKS Validation · OpenFGC Integration** --- -## Table of Contents - -1. [Architecture Overview](#1-architecture-overview) -2. [Core Design Decisions](#2-core-design-decisions) -3. [Split Cookie Strategy](#3-split-cookie-strategy) -4. [Authentication Flows](#4-authentication-flows) -5. [Endpoints & Routes](#5-endpoints--routes) -6. [Middleware Stack](#6-middleware-stack) -7. [Core Functions & Signatures](#7-core-functions--signatures) -8. [Security](#8-security) -9. [Project Structure](#9-project-structure) -10. [Configuration](#10-configuration) -11. [Deployment Notes](#11-deployment-notes) -12. [Implementation Checklist](#12-implementation-checklist) -13. [Patterns, Practices, and Standards](#13-patterns-practices-and-standards) - ---- - -## 1. Architecture Overview - -``` -┌──────────────────────────┐ -│ React Portal │ -│ (portal/frontend) │ -└──────────────────────────┘ - │ - ▼ -┌──────────────────────────────────────────────────────────┐ -│ Stateless BFF (Go) Port: 8080 │ -│ (portal/backend) │ -│ │ -│ ✅ No sessions / no state storage │ -│ ✅ Cookie 1 — auth_token (JWS, RS256) │ -│ ✅ Cookie 2 — session_token (JWE, AES-256-GCM) │ -│ ✅ HttpOnly + SameSite=Strict on both cookies │ -│ ✅ Stateless CSRF (HMAC double-submit) │ -│ ✅ OIDC flow with external IdP │ -│ ✅ Request proxying to OpenFGC │ -└──────────────────────────────────────────────────────────┘ - │ - ┌─────┴──────┐ - ▼ ▼ -┌──────────┐ ┌────────────────────┐ -│ OIDC IdP│ │ OpenFGC Backend │ -│ │ │ (/consent-server) │ -│ │ │ Port: 9090 │ -└──────────┘ └────────────────────┘ -``` +## 1. Purpose and Architecture + +The portal backend is both an OIDC confidential client and the protected API boundary. It performs login, authorization-code exchange, token refresh, logout, and JWT validation without a frontend IdP SDK. Access and refresh tokens use split-token transport: JavaScript sends the readable first half while the browser supplies the secure HTTP-only second half. The BFF reconstructs and validates the complete access token on every protected request before deriving identity or authorization context. + +``` +┌────────────────────────────────────────────────────┐ +│ React Portal │ +│ - No IdP SDK or client secret │ +│ - Starts login through BFF auth endpoints │ +│ - Sends Authorization: Bearer │ +└───────────────────────────┬────────────────────────┘ + │ HTTPS + ▼ +┌──────────────────────────────────────────────────────┐ +│ Portal Backend / BFF (Go, port 8080) │ +│ - OIDC confidential client and OAuth resource server │ +│ - Reconstructs tokens from request + HTTP-only cookie│ +│ - Validates ID/access tokens using IdP JWKS │ +│ - Enforces route, scope, organization, and ownership │ +│ - Maps approved requests to OpenFGC │ +└───────────────────────────┬──────────────────────────┘ + │ private network + ▼ +┌──────────────────────────────────────────────────────┐ +│ OpenFGC server (port 8060) │ +│ - Internal downstream API │ +│ - Receives only BFF-derived trusted context headers │ +└──────────────────────────────────────────────────────┘ + + ┌──────────────────────────────────────┐ + │ IdP │ + │ Authorization server / OIDC provider │ + └──────────────────────────────────────┘ +``` + +### Responsibilities + +| Component | Responsibilities | Must not do | +|---|---|---| +| React Portal | Start BFF login/logout, read token part 1, attach it as a partial Bearer value, call refresh, and decode the ID token for profile display | Use an IdP SDK, hold a client secret, reconstruct complete access/refresh tokens, or make authorization decisions from UI data | +| Portal BFF | Act as the OIDC confidential client; exchange codes; issue, reconstruct, refresh, and clear split-token cookies; validate JWTs; derive principal; authorize requests; protect OpenFGC boundary | Expose complete access/refresh tokens to JavaScript or trust token claims before JWKS validation | +| IdP | Authenticate users and issue tokens | Be bypassed by the portal or BFF | +| OpenFGC | Execute internal consent operations | Be reachable directly from the browser/public network | ### Assumptions -- Repository paths used by this plan: - - React portal: `portal/frontend` - - Stateless BFF: `portal/backend` - - OpenFGC backend: `/consent-server` - -- The selected OIDC provider is configured to issue **JWT access tokens** (not opaque). This must be confirmed before implementation — see [§8.6](#86-provider-token-format-assumption). -- The selected OIDC provider is configured to issue **refresh tokens** to this client (typically requires `offline_access` scope and provider policy permitting refresh issuance). -- Rate limiting on auth endpoints is enforced at the **infrastructure layer** (gateway / reverse proxy), not in the BFF — see [§11.1](#111-rate-limiting). -- Refresh token race conditions across BFF replicas use a single v1 policy: accept `401` on refresh collision and force re-login — see [§11.2](#112-refresh-race-condition). -- Local development runs all browser-facing services on `localhost` with different ports: - - Portal: `http://localhost:3000` - - BFF: `http://localhost:8080` - - OIDC IdP: `https://localhost:9443` +- The selected IdP issues signed JWT access tokens for the portal API audience and supports OIDC discovery/JWKS. +- The BFF is registered as an OAuth confidential client using Authorization Code flow. The client secret is available only to the BFF. +- The confidential-client flow uses OAuth `state` and S256 PKCE with one-time, short-lived HTTP-only transaction cookies. OIDC `nonce` remains deferred to a separate hardening change. +- The frontend and BFF are deployed on the same host in production, normally behind one ingress, so the host-only part-1 cookies are readable by the portal. If they use different origins on that host, CORS credentials and exact origin allowlisting are required. +- The validated token `sub` is the same identifier OpenFGC uses in `userIds`. +- The BFF and OpenFGC communicate on a private network. OpenFGC is available at `http://localhost:8060` in local development. +- OpenFGC trusts BFF-derived identity/context headers. If OpenFGC later validates user access tokens itself, that is a separate downstream-authentication design decision. --- ## 2. Core Design Decisions | Decision | Rationale | -|----------|-----------| -| Split cookies (JWS + JWE) | Encrypt only what needs to be secret. Identity claims are not sensitive; bearer tokens are. | -| RS256 for auth_token | Asymmetric signing — verification uses public key only. | -| AES-256-GCM for session_token | Hardware-accelerated (~1µs/op). Authenticated encryption — confidentiality and integrity in one operation. | -| HttpOnly on both cookies | JS cannot access auth_token or session_token regardless of content. | -| SameSite=Strict on all cookies | Primary CSRF defence. Blocks cross-site cookie submission without server state. | -| HMAC-signed CSRF token | Stronger than random double-submit — prevents subdomain cookie injection. No server storage required. | -| Encrypted OIDC state (AEAD) | Keeps PKCE verifier and nonce confidential in the front channel while staying stateless. | -| RP-initiated logout | Terminates IdP session on logout when provider supports end-session. Prevents silent re-authentication. | -| 15-minute token expiry | Limits blast radius of cookie theft. Short enough to matter, long enough not to force constant refresh. | -| Versioned encryption keys | Allows key rotation without mass logout — old keys retained for one TTL window during transition. | -| No server-side session store | Enables horizontal scaling with no coordination. Tradeoff: no per-token revocation (see [§8.5](#85-known-tradeoff-revocation)). | -| Go 1.22 stdlib mux | Go 1.22 stdlib ServeMux supports method routing and path parameters natively. | +|---|---| +| BFF performs OIDC as a confidential client | Keeps the client secret and token endpoint interaction outside the browser and removes the frontend IdP SDK dependency. | +| Stateless login correlation and S256 PKCE | Binds each callback to one initiating browser using one-time HTTP-only state/verifier cookies without a server session, shared store, encryption key, or signing key. | +| Split-token transport | A complete access or refresh token is not JavaScript-readable; the readable half alone is unusable away from the browser session. | +| Partial Bearer header plus HTTP-only cookie | The frontend sends access-token part 1 as `Authorization: Bearer ` and the browser supplies part 2 as a secure HTTP-only cookie. | +| BFF remains a JWT resource server | Reconstructing a token is transport handling only; the BFF still validates it through issuer discovery/JWKS on every protected request. | +| Refresh token split across request body and cookie | Refresh-token part 1 is posted to `/auth/refresh`; part 2 remains HTTP-only and is never returned in a response body. | +| JavaScript-readable ID token | The frontend may decode the ID token for display/profile data, but neither the frontend nor BFF uses those browser-decoded claims for authorization. | +| JWT validation through issuer discovery/JWKS | Validates ID and access tokens from the configured IdP while supporting signing-key rotation. | +| Explicit claim-to-principal mapping | Prevents business authorization from depending on unstructured token payloads. | +| Canonical scope registry | `internal/system/auth/scopes.go` is the single runtime source of truth for portal scope names; route policies, configuration validation, and tests reference its typed constants. | +| Allowlisted BFF routes | Prevents an unrestricted proxy to OpenFGC. | +| BFF-derived upstream headers only | Browser-provided identity, organization, and client headers are never trusted. | +| OpenFGC private-only exposure | Makes the BFF the mandatory policy-enforcement point. | --- -## 3. Split Cookie Strategy - -### 3.1 Why Split? - -The original design stored the OpenFGC bearer token inside a single JWE cookie. While encrypted, this created a single high-value target and made per-token revocation impossible. Splitting separates concerns: identity (low sensitivity, needs integrity) from token material (high sensitivity, needs confidentiality). - -### 3.2 Cookie 1 — `auth_token` (JWS) +## 3. Authentication and Request Flows -Contains user identity claims only. The payload is not secret — the user already knows their own identity. The security requirement is **integrity**, not confidentiality: we must prevent the user from tampering with their roles or subject identifier. A signed JWT (RS256) achieves this without encryption overhead. +### 3.1 Frontend Authentication Contract -``` -Format: JWS (signed JWT, RS256) -Storage: HttpOnly cookie -Expiry: 15 minutes - -Payload: -{ - "sub": "user123", - "email": "user@example.com", - "name": "John Doe", - "roles": ["user", "admin"], - "iat": 1711836000, - "exp": 1711836900, - "iss": "http://localhost:8080", - "aud": "portal" -} - -Cookie config: - Name: auth_token - Path: / - HttpOnly: true - Secure: true - SameSite: Strict - MaxAge: 900 -``` - -### 3.3 Cookie 2 — `session_token` (JWE) - -Contains high-sensitivity token material used by the BFF: OIDC refresh token (and optional provider session metadata). OIDC ID token is intentionally not stored in `session_token` to keep cookie size within browser-safe limits. If a user could read this cookie and extract these values, they could mint fresh provider tokens outside the BFF. **Encryption is required here.** The `kid` header field carries the key version to support rotation without mass logout. +The frontend uses a small portal-owned auth client, not an IdP SDK. It knows only the BFF auth routes, the readable cookie names, and the partial-token transport contract. ``` -Format: JWE (AES-256-GCM encrypted, kid-versioned) -Storage: HttpOnly cookie -Expiry: Matches OpenFGC token TTL (15 minutes) - -Payload: -{ - "refresh_token": "idp_refresh_token_abc...", - "exp": 1711836900 -} - -JWE Header: -{ - "alg": "A256GCMKW", - "enc": "A256GCM", - "kid": "2" ← key version for rotation -} - -Cookie config: - Name: session_token - Path: / - HttpOnly: true - Secure: true - SameSite: Strict - MaxAge: 900 +PortalAuthClient + - isAuthenticated() + - getUserProfile() // reconstruct and decode ID-token parts locally + - login() // navigate to /auth/login + - refresh() // POST refresh-token part 1 + - logout(returnTo?) // POST /auth/logout + - attachAccessTokenPart1(request) ``` -### 3.4 Cookie 3 — `csrf_token` (non-HttpOnly) +Portal components must not parse token claims for authorization. The API client reads access-token part 1 from its secure, JavaScript-readable cookie and sends `Authorization: Bearer `. It must use credentials-enabled requests so the browser also sends the HTTP-only part-2 cookie. -Not a bearer credential — contains only a derived HMAC token. Must be readable by JS so the portal can include it in the `X-CSRF-Token` header. +### 3.2 Login and Callback ``` -Format: HMAC-SHA256(sub + iat, csrfHMACKey) -Storage: Non-HttpOnly cookie (intentionally JS-readable) -Expiry: 15 minutes - -Cookie config: - Name: csrf_token - Path: / - HttpOnly: false ← intentional - Secure: true - SameSite: Strict - MaxAge: 900 +1. The frontend navigates to `GET /auth/login`. +2. The BFF generates cryptographically random OAuth state and PKCE verifier values, stores them in short-lived HTTP-only cookies, and redirects to the IdP with the state and S256 code challenge. +3. The user authenticates at the IdP. +4. The IdP redirects to `GET /auth/callback?code=...&state=...`. +5. The BFF consumes the transaction cookies, validates the returned state using constant-time comparison, and exchanges the code with the matching PKCE verifier and its confidential-client authentication method. +6. The BFF validates the ID token through discovery/JWKS, including signature, allowed algorithm, + issuer, client audience, and expiry. OIDC nonce validation remains deferred. +7. The BFF validates the access token through discovery/JWKS, splits the returned tokens, sets the + token cookies, and redirects to the configured portal URL. ``` -### 3.5 Cookie Comparison +The BFF must reject missing or duplicate codes/state values, missing or duplicate transaction cookies, state mismatch, replayed callbacks, token-exchange failures, and invalid returned tokens. Transaction cookies are consumed before code exchange and never reused. Authorization codes, state, PKCE verifiers, and tokens must never be logged. State and PKCE provide login-CSRF and authorization-code interception protection; OIDC nonce-based ID-token replay correlation remains deferred. -| Property | auth_token | session_token | csrf_token | -|----------|-----------|---------------|------------| -| Contents | sub, email, roles, exp | refresh_token, exp | HMAC-derived token | -| Sensitivity | Low | High | None | -| Format | JWS (RS256) | JWE (AES-256-GCM) | Plain string | -| Encrypted | No | Yes | No | -| Signed | Yes (RS256) | Yes (implicit in JWE) | Yes (HMAC) | -| HttpOnly | Yes | Yes | **No** | -| SameSite | Strict | Strict | Strict | -| Expiry | 15 min | Matches OpenFGC TTL | 15 min | -| Key versioned | Yes (kid in header) | Yes (kid in header) | No | +### 3.3 Token Cookie and Transport Contract -Session cookie size guard (required): -- Before setting `session_token`, compute the final serialized cookie length (value + attributes). -- Enforce a hard cap via `SESSION_COOKIE_MAX_BYTES` (recommended: 3500 bytes). -- If size exceeds cap, fail closed (clear auth cookies and return 401) instead of writing an oversized cookie. +Tokens are split at a deterministic midpoint and reconstructed only in BFF auth middleware/services. Cookie names are configurable; the logical defaults below describe the contract. -### 3.6 Why Not Encrypt Cookie 1? +| Token | Part 1 | Part 2 | Request transport | +|---|---|---|---| +| Access token | JavaScript-readable secure cookie `__Host-portal-at-p1` | Secure HTTP-only cookie `__Host-portal-at-p2` | Part 1 in `Authorization: Bearer `; part 2 automatically attached as a cookie | +| Refresh token | JavaScript-readable secure cookie `__Host-portal-rt-p1` | Secure HTTP-only cookie `__Host-portal-rt-p2` | Part 1 in the `POST /auth/refresh` body; part 2 automatically attached as a cookie | +| ID token | JavaScript-readable secure cookie `__Host-portal-id-p1` | JavaScript-readable secure cookie `__Host-portal-id-p2` | Frontend may reconstruct and decode it for display/profile data; the BFF may reconstruct it as an end-session hint only after appropriate validation; never accepted as API credentials | -Cookie 1 carries identity claims that are not confidential; integrity is the requirement. Signing with RS256 provides tamper protection. Cookie 2 still requires encryption because it contains bearer material. +Login transactions use two additional configurable, secure HTTP-only cookies: OAuth state and the PKCE verifier. They contain independent cryptographically random values, expire within at most ten minutes, use `SameSite=Lax` by default, and are cleared before callback validation or token exchange. They are not encrypted because they are ephemeral correlation secrets, and tampering can only invalidate the login transaction. No server-side session or BFF-owned cryptographic key is introduced. Production `__Host-` names require `Secure`, no `Domain`, and `Path=/`; local HTTP development may use non-`__Host-` names. A new login in the same browser replaces the previous outstanding transaction, so only the most recently initiated login can complete. -### 3.7 Recommended Cookie Prefix Hardening +All production cookies use `Secure`, `Path=/`, no `Domain`, and an explicitly configured `SameSite` value. Access/refresh part-2 cookies use `HttpOnly`; part-1 and both ID-token cookies are JavaScript-readable. Splitting the ID token is for cookie-size handling only—the complete ID token remains available to JavaScript. Cookie expiry must not exceed the corresponding token expiry. Development cookie names may omit the `__Host-` prefix only when local HTTP operation makes secure cookies impossible. -For production deployments, prefer the `__Host-` cookie prefix for all BFF cookies (for example: `__Host-auth_token`, `__Host-session_token`, `__Host-csrf_token`). +Each cookie must remain below a conservative browser-compatible size limit. If any token cannot fit within its defined cookie parts, authentication fails with a generic error; the BFF must not silently truncate it. Minimize unnecessary ID-token claims rather than adding unbounded cookie fragments. -`__Host-` requires all of the following: -- `Secure=true` -- `Path=/` -- No `Domain` attribute +The split limits complete-token exfiltration but does not neutralize XSS: injected same-origin JavaScript can still issue requests while the browser supplies part 2. A restrictive frontend CSP and normal XSS controls remain mandatory. -These constraints prevent accidental subdomain scoping and reduce cookie injection risk from sibling subdomains. +### 3.4 Protected BFF Request -If `__Host-` naming is enabled, `COOKIE_DOMAIN` must remain empty in all environments. +``` +React Portal + Authorization: Bearer + Cookie: __Host-portal-at-p2= + │ + ▼ +Portal BFF + 1. Parse exactly one partial Bearer Authorization header. + 2. Read exactly one configured access-token part-2 cookie. + 3. Enforce per-part and reconstructed-token size limits and concatenate part 1 + part 2. + 4. Obtain verification keys from the pinned issuer discovery/JWKS cache. + 5. Validate the reconstructed JWT signature and registered claims. + 6. Map validated claims to an authenticated principal. + 7. Enforce route-level scope, organization, and ownership policy. + 8. Strip client-supplied trusted headers and inject BFF-derived context. + 9. Map the allowlisted route to OpenFGC. + │ + ▼ +OpenFGC server :8060 +``` ---- +Missing either half, duplicate cookies/authorization values, malformed parts, or a failed reconstructed-token validation returns `401`. Neither partial nor reconstructed token is logged or placed in request context. -## 4. Authentication Flows +### 3.5 Token Validation Requirements -### 4.1 OIDC Login +For every protected request, the BFF must validate the reconstructed access token: -``` -1. User visits http://localhost:3000 -2. Portal calls GET /auth/me -3. If `/auth/me` returns 401 → GET /auth/login -4. If `/auth/me` returns 200 → continue normally -5. BFF generates PKCE + nonce: - code_verifier = random(32 bytes, base64url) - code_challenge = base64url(SHA256(code_verifier)) - oidc_nonce = random(16 bytes, base64url) -6. BFF generates encrypted state carrying callback-bound values: - state = Encrypt(state_payload{timestamp, code_verifier, oidc_nonce}, STATE_ENC_KEY) -7. BFF redirects to OIDC provider authorize endpoint: - - ?client_id=bff_client - &redirect_uri=http://localhost:8080/auth/callback - &response_type=code - &scope=openid profile email offline_access - &nonce= - &code_challenge= - &code_challenge_method=S256 - &state= - 8. User authenticates at IdP - 9. IdP redirects: /auth/callback?code=...&state=... - 10. BFF validates state: - - Decrypt and authenticate with STATE_ENC_KEY - - Extract timestamp, code_verifier, oidc_nonce - - Reject if decrypt/authentication fails - - Reject if timestamp older than STATE_MAX_AGE_MINUTES - 11. BFF exchanges code for tokens with PKCE verifier (backend call — never exposed to client) - 12. BFF fully validates ID token (signature via JWKS, `iss`, `aud`, `exp`/`iat`/`nbf`, and `nonce == oidc_nonce`) - 13. BFF issues Cookie 1 (auth_token, JWS) with identity claims - 14. BFF issues Cookie 2 (session_token, JWE) with refresh-token material - 15. BFF issues Cookie 3 (csrf_token, non-HttpOnly) with HMAC token - 16. BFF redirects to Portal - 17. Portal reads csrf_token cookie, stores value for subsequent request headers -``` +- JWT signature using the configured IdP JWKS and an explicitly allowed signing algorithm. +- `iss` equals `BFF_AUTH__ISSUER_URL`. +- `aud` contains the configured portal API audience. +- `exp`, `nbf` (when present), and `iat` (when required by policy), allowing only configured clock skew. +- A non-empty `sub` claim. +- Token type/purpose only when `BFF_AUTH__REQUIRE_ACCESS_TOKEN_TYPE=true`, so an ID token cannot be accepted as an API access token when the IdP provides a reliable distinguishing claim. +- Required scopes and organization claims for the target route. -### 4.2 Authenticated Request +The callback separately validates the ID token against the OIDC client ID; nonce validation remains deferred. The BFF must reject complete browser-supplied tokens, query-string tokens, bearer values without the matching part-2 cookie, unsigned tokens, unsupported algorithms, unknown issuers/audiences, and expired tokens. -``` -Portal → BFF - Cookie: auth_token=; session_token=; csrf_token= - X-CSRF-Token: - -BFF middleware: - 1. Verify auth_token RS256 signature → extract identity claims - 2. Decrypt session_token (kid → select key) → ensure valid BFF session material - 3. Validate CSRF (all non-safe methods: anything except GET/HEAD/OPTIONS/TRACE): cookie/header match + HMAC recomputation - using trusted auth claims (`sub`, `iat`) in constant time - 4. Derive trusted upstream headers from validated identity/context: - - org-id - - TPP-client-id (for create/update routes) - - X-Correlation-ID (propagate or generate) - 5. Rewrite path /api/... → /api/v1/... before proxying to consent-server - -Proxy → OpenFGC (port 9090) - ↓ -Response returned to Portal -``` +### 3.6 Refresh -### 4.3 Token Refresh +`POST /auth/refresh` accepts refresh-token part 1 in an `application/x-www-form-urlencoded` body and obtains part 2 only from the configured HTTP-only cookie. It reconstructs the refresh token, authenticates the confidential client to the token endpoint, and never returns tokens in its response body. -``` -Portal → POST /auth/refresh - X-CSRF-Token: - Cookie: auth_token=; session_token= - -BFF: - 1. Validate CSRF: cookie/header match + HMAC recomputation - using signed auth_token claims (`sub`, `iat`) - 2. Decrypt session_token → extract refresh_token - 3. Validate auth_token signature/sub consistency; - allow expiry only within a small grace window (recommended: 5 minutes) - 4. POST - grant_type=refresh_token - refresh_token= - 5. Receive refreshed provider tokens (+ new refresh_token if rotation enabled) - 6. Re-validate identity source before reissuing cookies: - - preferred: validate returned `id_token` (signature/JWKS, iss, aud, exp/iat/nbf) - - fallback: call UserInfo with refreshed access token and verify `sub` consistency - 7. Reissue session_token (JWE) with updated refresh-token material - 8. Reissue auth_token (JWS) on every successful refresh to reset expiry - 9. Reissue csrf_token using new auth claims (`sub`, `iat`) whenever auth_token is reissued - 10. Return 200 — portal continues with updated cookies - -On invalid_grant from IdP: - → Clear all cookies - → Return 401 - → Portal redirects to /auth/login -``` +On success, the BFF atomically replaces both access-token cookies, both refresh-token cookies when the IdP rotates the refresh token, and both JavaScript-readable ID-token cookies when a new ID token is returned. It clears stale token cookies if refresh fails because the refresh token is invalid or expired. The frontend uses one module-level refresh promise to deduplicate concurrent `401` recovery within one browser runtime. Matching the reference server behavior, this stage adds no BFF-local or distributed refresh lock; cross-tab and cross-replica rotation races remain a documented limitation. -> **Note:** The race condition that can occur when multiple BFF replicas simultaneously refresh the same token is addressed at deployment time. See [§11.2](#112-refresh-race-condition). +`/auth/refresh` must reject cookie-only requests. Possession of refresh-token part 1 in the explicit request body, combined with the matching HTTP-only part-2 cookie, is the CSRF defense used by the reference split-cookie design. A cross-site origin cannot read part 1 through the same-origin policy and cannot reconstruct the refresh token from automatically attached cookies alone. -### 4.4 Logout +### 3.7 Profile, Logout, and Expiry -``` -Portal → POST /auth/logout - Cookie: auth_token/session_token/csrf_token (as available) - -BFF: - 1. Validate same-origin request using strict algorithm: - - If `Origin` is present: require origin to match one configured value in `PORTAL_ALLOWED_ORIGINS` - - Else if `Referer` is present: parse and require referer origin to match one configured value in `PORTAL_ALLOWED_ORIGINS` - - Else: reject request (403) - 2. Clear cookies using the same attributes used at issuance (name/path/domain/samesite/secure) - so browser deletion is reliable across environments. - Examples: - - Set-Cookie: auth_token=; Path=/; Domain=; MaxAge=-1; HttpOnly; Secure; SameSite=Strict - - Set-Cookie: session_token=; Path=/; Domain=; MaxAge=-1; HttpOnly; Secure; SameSite=Strict - - Set-Cookie: csrf_token=; Path=/; Domain=; MaxAge=-1; Secure; SameSite=Strict - 3. If provider exposes `end_session_endpoint`, call RP-initiated logout endpoint: - GET - ?post_logout_redirect_uri=http://localhost:3000/login - 4. Redirect to http://localhost:3000/login - -Note: `auth_token` is not required for logout. CSRF header is not required for this endpoint. -Note: `id_token_hint` is intentionally omitted because `id_token` is not persisted in `session_token` (cookie-size control). -Provider-compatibility note: if an IdP requires `id_token_hint` for complete RP-initiated logout, fall back to local cookie clear + front-channel redirect to portal login. -``` +- The frontend may reconstruct and decode the JavaScript-readable ID-token parts for profile display only. Browser-decoded claims are never sent back as trusted identity or used for authorization. +- `POST /auth/logout` requires the same partial Bearer header and matching HTTP-only access-token part-2 cookie as other protected requests. It reconstructs/revokes tokens when supported, clears every token cookie using the exact issuance attributes, and initiates the IdP end-session flow when configured. +- On a protected-route `401`, the frontend may attempt one refresh and retry the original request once. If refresh fails, it starts login; it must not create retry loops. +- On `403`, the frontend shows an authorization error and must not refresh or repeatedly start login. +- Expired or invalid split cookies are cleared at the authentication boundary where safe to do so. --- -## 5. Endpoints & Routes - -### 5.1 Authentication Endpoints - -| Endpoint | Method | Description | Auth Required | -|----------|--------|-------------|---------------| -| `/auth/login` | GET | Generates encrypted state, redirects to OIDC provider | No | -| `/auth/callback` | GET | Validates state, exchanges code, issues all cookies | No | -| `/auth/me` | GET | Returns authenticated user identity for portal bootstrap (200/401) | `auth_token` | -| `/auth/logout` | POST | Validates same-origin (`Origin`/`Referer`), clears cookies, calls IdP end-session endpoint when available | Same-origin check (auth_token not required) | -| `/auth/refresh` | POST | Decrypts session_token, refreshes via IdP token endpoint, reissues cookies | Session + CSRF + signed auth_token (expiry allowed only in short grace window) | - -### 5.2 User Endpoints (BFF-defined, portal-facing) - -These endpoints are purpose-built for data owners (end users). They are not generic passthrough routes. - -| Endpoint | Method | Description | Auth Required | -|----------|--------|-------------|---------------| -| `/me/consents` | GET | Self-consent list endpoint. BFF always enforces user scope. | Yes | -| `/me/consents/{consentId}` | GET | Retrieve one consent only if owned by authenticated user. | Yes | -| `/me/consents/{consentId}/approve` | POST | Approve consent for the authenticated user and persist approval decision. | Yes | -| `/me/consents/{consentId}/revoke` | PUT | Revoke one consent only if owned by authenticated user. | Yes | - -User mapping notes: -- `GET /me/consents` maps to upstream `GET /api/v1/consents?userIds=` -- Any browser-supplied `userIds` is ignored/overwritten for user role requests -- `POST /me/consents/{consentId}/approve` maps to upstream consent approval operations (`PUT /api/v1/consents/{consentId}` and/or `POST /api/v1/consents/{consentId}/authorizations`) with `status=APPROVED` -- `GET /me/consents/{consentId}` and `PUT /me/consents/{consentId}/revoke` require ownership validation before proxying -- `POST /me/consents/{consentId}/approve` requires ownership validation and must ignore any client-supplied `userId` that does not match principal `sub` - -### 5.3 Admin Endpoints (BFF-defined + controlled 1:1 passthrough) - -These endpoints are for data holder admins. They expose broader search/management capability and may call consent-server routes in a 1:1 pattern after policy checks. - -| Endpoint | Method | Description | Auth Required | -|----------|--------|-------------|---------------| -| `/admin/consents` | GET, POST | List / create consents with admin scope. | Admin | -| `/admin/consents/attributes` | GET | Search consents by attribute key/value. | Admin | -| `/admin/consents/validate` | POST | Validate consent access payloads. | Admin | -| `/admin/consents/{consentId}` | GET, PUT | Get / update consent by ID. | Admin | -| `/admin/consents/{consentId}/revoke` | PUT | Revoke consent by ID. | Admin | -| `/admin/consents/{consentId}/authorizations` | GET, POST | List / create authorization resources. | Admin | -| `/admin/consents/{consentId}/authorizations/{authorizationId}` | GET, PUT | Get / update authorization resource. | Admin | -| `/admin/consent-elements` | GET, POST | List / create consent elements. | Admin | -| `/admin/consent-elements/{elementId}` | GET, PUT, DELETE | Get / update / delete consent element. | Admin | -| `/admin/consent-elements/validate` | POST | Validate consent element names. | Admin | -| `/admin/consent-purposes` | GET, POST | List / create consent purposes. | Admin | -| `/admin/consent-purposes/{purposeId}` | GET, PUT, DELETE | Get / update / delete consent purpose. | Admin | - -Admin mapping notes: -- `/admin/*` routes map to consent-server `/api/v1/*` using explicit method/path allowlist -- BFF can preserve admin filters (including `userIds`) only after admin-role authorization passes -- Catch-all passthrough is not exposed to users; deny-by-default outside allowlisted routes - -### 5.4 Authorization and Routing Rules - -- Two actor types are enforced in BFF: data owner user and data holder admin -- User endpoints are self-scoped by default and never trust browser-provided scope -- Admin endpoints allow organization-wide access only after explicit role checks -- Object-level ownership checks are mandatory for user routes targeting a specific consent ID -- BFF strips untrusted client headers and re-creates trusted upstream headers (`org-id`, `TPP-client-id`, `X-Correlation-ID`) from validated context -- Path rewrite contract: - - User route group: `/me/*` -> mapped by BFF to selected `/api/v1/*` upstream routes - - Admin route group: `/admin/*` -> mapped by BFF to allowlisted `/api/v1/*` upstream routes +## 4. API Routes and Authorization + +Every path and method currently defined in `portal/backend/openapi/portal-backend.yaml` is listed below. Routes require a successfully reconstructed and validated access token unless explicitly marked public. Authorization is evaluated from the validated principal, never from browser-supplied user or organization values. The BFF authorizes capabilities through scopes; IdP roles are used only to grant scope bundles and are not evaluated by the BFF. + +### 4.1 Public System Endpoints + +| Endpoint | Method | Description | Authentication | +|---|---|---|---| +| `/health` | GET | Service health. | Public | +| `/health/liveness` | GET | Process liveness check. | Public | +| `/health/readiness` | GET | Dependency/readiness check. | Public | + +Health endpoints expose only minimal operational status. They must not return configuration, identity, token, dependency credentials, stack traces, or other sensitive diagnostics. + +### 4.2 User Endpoints + +| Endpoint | Method | Description | Required scope | Additional authorization | +|---|---|---|---|---| +| `/me/consents` | GET | List the authenticated user's consents. | `portal:consents:read:self` | Self-scoped to `UserIdentity.UserID` | +| `/me/consents/{consentId}` | GET | Get enriched details for an owned consent. | `portal:consents:read:self` | Ownership validation | +| `/me/consents/{consentId}/approve` | POST | Approve selected optional elements for an owned consent. | `portal:consents:write:self` | Ownership validation | +| `/me/consents/{consentId}/revoke` | POST | Revoke an owned consent. | `portal:consents:write:self` | Ownership validation | + +Rules: + +- `GET /me/consents` maps to `GET /api/v1/consents?userIds=` on OpenFGC. +- Ignore or overwrite every browser-supplied `userIds` value on `/me/*` routes. +- Before reading, approving, or revoking a specific consent, verify ownership against the authenticated `sub`. +- Ignore any request body `userId` that differs from `UserIdentity.UserID`. +- Purpose data fetched internally to enrich `/me/consents` responses is covered by the parent consent scope; it does not grant independent purpose browsing or management. + +### 4.3 Controlled `/api/*` Passthrough Endpoints + +`/api/{path...}` is the existing controlled passthrough route. It is split-token authenticated and restricted by the explicit method/path allowlist; it is not an unrestricted proxy. + +| BFF route | Methods | Required scope | +|---|---|---| +| `/api/consents` | GET | `portal:consents:read:any` | +| `/api/consents` | POST | `portal:consents:write:any` | +| `/api/consents/attributes` | GET | `portal:consents:read:any` | +| `/api/consents/validate` | POST | `portal:consents:read:any` | +| `/api/consents/{consentId}` | GET | `portal:consents:read:any` | +| `/api/consents/{consentId}` | PUT | `portal:consents:write:any` | +| `/api/consents/{consentId}/history` | GET | `portal:consents:read:any` | +| `/api/consents/{consentId}/revoke` | POST | `portal:consents:write:any` | +| `/api/consents/{consentId}/authorizations` | GET, POST | `portal:consents:read:any` for GET; `portal:consents:write:any` for POST | +| `/api/consents/{consentId}/authorizations/{authorizationId}` | GET, PUT | `portal:consents:read:any` for GET; `portal:consents:write:any` for PUT | +| `/api/consent-elements` | GET, POST | `portal:elements:read` for GET; `portal:elements:write` for POST | +| `/api/consent-elements/{elementId}` | GET | `portal:elements:read` | +| `/api/consent-elements/{elementId}/versions` | GET, POST | `portal:elements:read` for GET; `portal:elements:write` for POST | +| `/api/consent-elements/{elementId}/versions/{version}` | GET, DELETE | `portal:elements:read` for GET; `portal:elements:write` for DELETE | +| `/api/consent-purposes` | GET, POST | `portal:purposes:read` for GET; `portal:purposes:write` for POST | +| `/api/consent-purposes/{purposeId}` | GET | `portal:purposes:read` | +| `/api/consent-purposes/{purposeId}/versions` | GET, POST | `portal:purposes:read` for GET; `portal:purposes:write` for POST | +| `/api/consent-purposes/{purposeId}/versions/{version}` | GET, DELETE | `portal:purposes:read` for GET; `portal:purposes:write` for DELETE | + +Rules: + +- `:any` permits cross-user consent access only within `UserIdentity.OrgID`; the BFF preserves authorized `userIds` filters and always injects `org-id`. +- `:self` is intentionally available only through `/me/*`; the BFF overwrites the target user with `UserIdentity.UserID`. +- IdP role bundles are: end user receives `:self` scopes; DPO receives consent `:any` scopes; admin receives consent `:any` scopes plus element and purpose scopes. +- Consent history is a consent read operation. Listing or retrieving element/purpose versions requires the corresponding read scope; creating or deleting a version requires the corresponding write scope. +- Deny unknown `/api/*` paths and methods before proxying. + +### 4.4 Scope Enforcement Rules + +- Define every supported portal scope once as a typed constant in `internal/system/auth/scopes.go`. Production code must not repeat scope-name string literals in routers, middleware, handlers, services, or proxy policy definitions. +- `scopes.go` also exposes the immutable known-scope set used to validate configured requested scopes at startup. Unknown configured portal scopes cause startup failure; standard OIDC scopes such as `openid` and `profile` are validated separately. +- Route policy tables reference the typed scope constants. The frontend does not duplicate or enforce the scope registry because browser checks are not an authorization boundary. +- OpenAPI cannot import Go constants directly. Generate its scope descriptions from the canonical registry or add a contract test that fails when OpenAPI scope names differ from `scopes.go`. +- The BFF reads the space-delimited `scope` claim only after JWT signature and registered-claim validation. +- Missing, invalid, expired, or wrong-audience tokens return `401`; a valid token missing a required route scope returns `403`. +- Scope values use `:` within a scope name and spaces only to separate distinct scopes, for example: `portal:consents:read:self portal:consents:write:self`. +- The BFF never accepts scopes, user IDs, or organization context from request headers, paths, query parameters, or request bodies. --- -## 6. Middleware Stack - -### 6.1 Router Structure - -Auth routes and protected routes are registered on **separate mux instances**. This is structural safety — the middleware stack physically cannot intercept auth routes, regardless of registration order or Go mux precedence rules. - -To keep implementation lean, use a single `RegisterRoutes(...)` function and three focused tests: -- `/auth/login` and `/auth/callback` are not wrapped by auth/session/csrf middleware -- `/auth/me` is wrapped only by `Auth` middleware -- `/api/*` is always wrapped by `Auth` + `Session` + `CSRF` - -```go -// Base mux — public auth routes (no auth/session/csrf middleware) -mux := http.NewServeMux() -mux.HandleFunc("GET /auth/login", authHandler.Login) -mux.HandleFunc("GET /auth/callback", authHandler.Callback) - -// /auth/me mux — lightweight auth bootstrap endpoint for portal -meMux := http.NewServeMux() -meMux.HandleFunc("GET /auth/me", authHandler.Me) -me := AuthMiddleware(meMux) -mux.Handle("/auth/me", me) - -// Logout mux — same-origin-protected logout without auth-token requirement -logoutMux := http.NewServeMux() -logoutMux.HandleFunc("POST /auth/logout", authHandler.Logout) -logout := OriginCheckMiddleware(logoutMux) -mux.Handle("/auth/logout", logout) - -// Refresh mux — dedicated protection for token rotation -refreshMux := http.NewServeMux() -refreshMux.HandleFunc("POST /auth/refresh", authHandler.Refresh) -refresh := SessionMiddleware( - CSRFMiddleware(refreshMux)) -mux.Handle("/auth/refresh", refresh) - -// Protected mux — all routes require full middleware stack -protectedMux := http.NewServeMux() -protectedMux.HandleFunc("/api/{path...}", proxyHandler.Handle) - -// Wrap protected mux — order matters -protected := AuthMiddleware( - SessionMiddleware( - CSRFMiddleware(protectedMux))) - -mux.Handle("/api/", protected) - -// Apply global middleware so every route (including /auth/login/callback/logout) -// gets CORS, logs, and security headers. -root := SecurityHeadersMiddleware( - CORSMiddleware( - LoggerMiddleware(mux))) -// http.ListenAndServe(":8080", root) -``` +## 5. Middleware and Router Structure -### 6.2 Request Flow +### 5.1 Middleware Order ``` -Incoming Request - │ - ▼ -Security Headers Middleware - Adds: Content-Security-Policy, X-Content-Type-Options, - X-Frame-Options, Referrer-Policy - │ - ▼ -CORS Middleware - Access-Control-Allow-Origin: - Access-Control-Allow-Credentials: true (required for cookies) - OPTIONS preflight handled here - │ - ▼ -Logger Middleware - │ - ├─── Auth Routes (/auth/*) ────────────────────────────────────────────────┐ - │ Route-specific middleware only │ - │ GET /auth/login │ - │ GET /auth/callback │ - │ GET /auth/me (Auth middleware only) │ - └───────────────────────────────────────────────────────────────────────────┘ - │ - ├─── Logout Route (/auth/logout) ──────────────────────────────────────────┐ - │ Same-origin Middleware (`Origin`/`Referer`) │ - └───────────────────────────────────────────────────────────────────────────┘ - │ - ├─── Refresh Route (/auth/refresh) ────────────────────────────────────────┐ - │ Session Middleware │ - │ CSRF Middleware │ - │ Mandatory auth_token sub/signature consistency check │ - │ Strict error mapping (invalid_grant => clear cookies + 401) │ - │ No internal retry loop │ - └───────────────────────────────────────────────────────────────────────────┘ - │ - ▼ -Auth Middleware (/api/*) - 1. Extract auth_token cookie - 2. Verify RS256 signature - 3. Check expiry → 401 if expired - 4. Extract identity claims → attach to request context - │ - ▼ -Session Middleware - 1. Extract session_token cookie - 2. Read kid from JWE header → select decryption key - 3. AES-256-GCM decrypt → extract refresh/session material - 4. If kid != active version → reissue cookie with current key on response - │ - ▼ -CSRF Middleware (all non-safe methods: except GET, HEAD, OPTIONS, TRACE) - 1. Extract X-CSRF-Token header - 2. Extract csrf_token cookie value - 3. Require header == cookie value (double-submit) - 4. Recompute expected HMAC from trusted auth claims (`sub`, `iat`) - and compare in constant time → 403 if mismatch - │ - ▼ -Proxy Handler - Set: org-id, TPP-client-id (route-dependent), X-Correlation-ID - Rewrite: /api/* → /api/v1/* - Forward to OpenFGC +SecurityHeaders + → CORS + → RequestID / Logger + → SplitTokenAuth + → UserIdentity + → AuthorizationPolicy + → Route Handler / OpenFGC Proxy ``` ---- - -## 7. Core Functions & Signatures - -### 7.1 Cookie 1 — Identity Token (JWS) +`SplitTokenAuth` must run before `UserIdentity` and all protected route handlers. It reconstructs and validates the access token before storing the normalized principal. `UserIdentity` is the only resolver of effective user and organization identifiers; it stores both values in request context. `AuthorizationPolicy` uses the validated principal and route metadata to enforce scopes, organization rules, and ownership requirements. -```go -// Generate RS256-signed JWT containing identity claims and a kid header -func GenerateAuthToken(claims IdentityClaims, privateKey *rsa.PrivateKey, keyVersion string) (string, error) +### 5.2 Split-Token Authentication Middleware -// Read kid from JWT header, select verification key from configured key set, -// verify RS256 signature and return claims -func ValidateAuthToken(token string, publicKeys map[string]*rsa.PublicKey) (IdentityClaims, string /*kid*/, error) -``` +1. Require exactly one `Authorization` header matching `Bearer ` and reject comma-joined or duplicate authorization values. +2. Require exactly one configured HTTP-only access-token part-2 cookie and reject ambiguous duplicates. +3. Enforce conservative size and character limits on each half, concatenate part 1 followed by part 2, and retain the result only for validation. +4. Fetch provider metadata from the configured issuer discovery document at startup and use its `jwks_uri`. +5. Cache JWKS keys with bounded TTL and refresh the cache when an otherwise valid token references an unknown `kid`. +6. Validate signature, algorithm, issuer, audience, time claims, subject, and configured token-purpose claims. +7. Map claims into `Principal` only after successful validation. +8. Store `Principal` in request context; never store either token half or the reconstructed access token in context. +9. Return `401` with a generic error response and `WWW-Authenticate: Bearer` for failed authentication. -### 7.2 Cookie 2 — Session Token (JWE) +### 5.3 User Identity Middleware -```go -// Encrypt session claims (refresh/session material) with AES-256-GCM, -// embed kid in JWE header -func GenerateSessionToken(claims SessionClaims, keys map[string][]byte, activeKeyVersion string) (string, error) +`internal/system/middleware/identity.go` is the single, central resolver of effective request identity for `/me/*` handlers, services, and proxy transforms. -// Read kid from JWE header, select key, decrypt -// If kid != activeKeyVersion: caller should reissue cookie -func DecryptSessionToken(token string, keys map[string][]byte) (SessionClaims, string /*kid*/, error) -``` +- It writes one `UserIdentity` value into `internal/system/context`: -### 7.3 CSRF + ``` + UserIdentity { + UserID string // OpenFGC userIds; derived from token sub + OrgID string // OpenFGC org-id; derived from token org_id claim + } + ``` -```go -// HMAC-SHA256(sub + iat, csrfHMACKey) — tied to user identity -func GenerateCSRFToken(sub string, iat int64, hmacKey []byte) string +- In normal operation, it requires the `Principal` placed in request context by `SplitTokenAuth`, derives `UserIdentity` from the validated `Principal.Subject` and `Principal.OrgID`, and writes it through `system/context.WithUserIdentity`. +- In explicit placeholder mode, it derives the same `UserIdentity` from `PlaceholderUserID` and `PlaceholderOrgID`; handlers and services therefore use the identical contract in tests and local development. +- Placeholder mode is permitted only in local/test environments and is rejected during startup when `BFF_ENV=production`; this production safeguard already exists in `internal/system/config/config.go`. +- Placeholder mode must be an explicit configuration choice; it must never activate because authentication failed or because either token half is missing. +- Production routes always require split-token authentication; test/local route wiring may use `UserIdentity` with placeholder mode instead of `SplitTokenAuth` only when the explicit non-production configuration is enabled. -// Verify double-submit equality and HMAC authenticity in constant time -func ValidateCSRFToken(cookieValue, headerValue, sub string, iat int64, hmacKey []byte) bool -``` +Application handlers, services, and proxy transforms read `UserIdentityFromContext` only. `UserIDFromContext` may remain as a convenience wrapper over `UserIdentity.UserID` while existing code is migrated. They must not read JWT claims or placeholder configuration directly. -### 7.4 OIDC State +### 5.4 Authorization Policy -```go -// state = AEAD-encrypted payload {timestamp, code_verifier, oidc_nonce} -// No cookie or server storage required -func GenerateState(codeVerifier, oidcNonce string, encKey []byte) (string, error) +The principal model must contain only normalized, validated information: -// Decrypt + authenticate state, then check timestamp age -func ValidateState(state string, encKey []byte, maxAge time.Duration) (codeVerifier, oidcNonce string, err error) ``` - -### 7.5 PKCE / ID Token Validation Helpers - -```go -// RFC 7636 S256 PKCE pair -func GeneratePKCE() (codeVerifier, codeChallenge string, err error) - -// Full OIDC ID token validation: signature (JWKS), issuer, audience, -// time claims (exp/iat/nbf), and nonce binding -func ValidateIDToken(idToken, expectedNonce, expectedIssuer, expectedAudience string) error +Principal { + Subject string + Scopes []string + OrgID string +} ``` -### 7.6 OIDC Exchange +Claim names and mappings are configuration-driven because IdP claim conventions vary. Route policy must distinguish authentication failures (`401`) from authenticated-but-forbidden requests (`403`). -```go -// Exchange authorization code for IdP tokens (PKCE enabled) -func ExchangeCodeForToken(code, codeVerifier string) (*OIDCTokenResponse, error) +### 5.5 CORS -// Use refresh_token to obtain new access token -// Returns ErrInvalidGrant if IdP rejects (rotation consumed the token) -func RefreshAccessToken(refreshToken string) (*OIDCTokenResponse, error) +- Allow only origins in `BFF_CORS__ALLOWED_ORIGINS`; never use `*`. +- Allow `Authorization`, `Content-Type`, and `X-Correlation-ID` request headers. +- Allow only the BFF's supported HTTP methods. +- Return `Vary: Origin`. +- Set `Access-Control-Allow-Credentials: true` when the frontend and BFF are cross-origin, because protected requests require the part-2 cookie. Never combine credentials with a wildcard origin. +- Handle preflight `OPTIONS` before split-token authentication. -// Build provider end_session URL for RP-initiated logout (if supported) -func BuildLogoutURL(postLogoutRedirectURI string) string -``` +--- -### 7.7 Proxy +## 6. OpenFGC Proxy Boundary -```go -// Clone and forward request to OpenFGC, applying trusted header/path transforms -func ProxyToOpenFGC(r *http.Request, target *url.URL, upstreamHeaders map[string]string) (*http.Response, error) -``` +### 6.1 Request Transformation -Proxy security contract (required): -- Strip hop-by-hop headers (`Connection`, `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `TE`, `Trailer`, `Transfer-Encoding`, `Upgrade`) -- Ignore untrusted forwarding headers from clients; only add forwarding headers from trusted edge data -- Ignore incoming browser-supplied `org-id` and `TPP-client-id`; set both from trusted auth/context -- For user-scoped routes (for example `GET /me/consents`), inject `userIds=` and ignore browser-supplied `userIds` -- Propagate or generate `X-Correlation-ID` for every proxied request -- Preserve query string exactly while rewriting only the path prefix `/api/` → `/api/v1/` -- Enforce request body size limits before proxying -- Enforce upstream timeout budget and deterministic timeout mapping -- Do not retry non-idempotent methods automatically -- Enforce mandatory method/path allowlist in proxy handler logic (deny by default with 404/405) even when router uses `/api/{path...}` catch-all - -### 7.8 Key Rotation Helpers - -```go -// Load versioned keys from config -// e.g. JWE_KEYS={"1":"oldhex...","2":"newhex..."} -func LoadKeyMap(raw string) (map[string][]byte, error) - -// Return the active key for encryption -func ActiveKey(keys map[string][]byte, activeVersion string) ([]byte, error) -``` +For an authorized route, the proxy must: ---- +- Map only approved BFF routes to their corresponding OpenFGC `/api/v1/*` route. +- Preserve approved query parameters and request bodies while applying user-scoping rules. +- Strip hop-by-hop headers: `Connection`, `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `TE`, `Trailer`, `Transfer-Encoding`, and `Upgrade`. +- Remove any browser-supplied `org-id`, `TPP-client-id`, user, or identity headers. +- Inject only the trusted context explicitly defined in §6.2 from the validated principal and route policy. +- Propagate a validated `X-Correlation-ID`, or generate one when absent. +- Enforce request-size limits, downstream timeouts, and a conservative retry policy appropriate for idempotency. -## 8. Security +### 6.2 Trusted Context Mapping -### 8.1 XSS Protection +The BFF strips any browser-supplied values for the trusted headers below, then derives them only from validated request context. -- `HttpOnly` on `auth_token` and `session_token` — JS cannot read either cookie -- `csrf_token` is non-HttpOnly intentionally, but contains no sensitive credential -- Security headers on all responses (see [§8.7](#87-security-headers)) +| BFF source | OpenFGC target | Rule | +|---|---|---| +| `UserIdentity.UserID` | `userIds` query parameter | Set only for `/me/*` routes; overwrite all browser-supplied `userIds`. It is also used for user-owned authorization and action payloads. | +| `UserIdentity.OrgID` | `org-id` header | Set on every OpenFGC request. Reject the request if the required organization identity is absent. | +| Group ID read from the fetched consent resource | `group-id` header | Set only when a self-service update/revoke operation first reads the owned consent and must send that consent-bound group back upstream. Never derive a generic group ID from token claims or placeholder configuration. | +| No user/IdP-derived source | `TPP-client-id` header | Strip any browser value and do not set this header in the current design. If OpenFGC requires it, define a separate service-level trusted source before enabling that route. | +| Validated incoming correlation ID, or generated ID | `X-Correlation-ID` header | Propagate only after format/length validation; otherwise generate a new ID. | -### 8.2 CSRF Protection +The BFF never forwards the browser `Authorization` header or a browser-supplied trusted-context header to OpenFGC. -- `SameSite=Strict` on all cookies — primary defence, blocks cross-site submission -- HMAC double-submit — `csrf_token` cookie must match `X-CSRF-Token` header -- CSRF validation is required for all non-safe HTTP methods (anything except `GET`, `HEAD`, `OPTIONS`, `TRACE`) -- CSRF tokens are verified by server-side HMAC recomputation against trusted auth claims (`sub`, `iat`) with constant-time comparison -- Subdomain cookie injection cannot produce a valid token without the CSRF HMAC key -- `POST /auth/logout` is intentionally exempt from CSRF header validation to preserve no-JS logout resilience; it is protected by strict same-origin checks plus `SameSite=Strict` cookies. Matching algorithm: origin must match one configured value in `PORTAL_ALLOWED_ORIGINS` using `Origin`, else `Referer`, else reject. +### 6.3 Downstream Authentication -### 8.3 Token Security +This plan uses a header-trust boundary: OpenFGC trusts the BFF's injected context, not client-provided headers. The BFF does not forward the browser access token to OpenFGC by default. -- Cookie 1 (auth_token): RS256 — user can read payload, cannot forge claims -- Cookie 2 (session_token): AES-256-GCM — refresh/session material opaque to the user -- ID token is fully validated during callback: signature (JWKS), issuer, audience, time claims, and nonce -- 15-minute expiry limits window for cookie theft -- Provider refresh token rotation (if enabled) invalidates refresh tokens on use -- RP-initiated logout terminates IdP session — not just local cookies (when end-session is supported) +If OpenFGC must validate access tokens in the future, explicitly add one of these designs before forwarding credentials: -### 8.4 Key Management +- token forwarding with OpenFGC validation of the same audience/token contract; or +- OAuth token exchange / service-to-service credentials for the OpenFGC audience. -| Key | Used For | Rotation Impact | -|-----|----------|-----------------| -| RSA Private Key | Sign auth_token (Cookie 1) | Existing tokens unverifiable — see rotation procedure | -| RSA Public Keys (versioned set) | Verify auth_token (Cookie 1) via JWT `kid` lookup | Old/new keys can overlap during rotation | -| AES-256 Key (versioned) | Encrypt session_token (Cookie 2) | Old versions retained during transition | -| CSRF HMAC Key | Sign csrf_token | All CSRF tokens invalid — reissued on next auth | -| State Encryption Key | Encrypt/decrypt OIDC state | In-flight logins fail — negligible impact | +Do not forward a token merely because it is available; its audience and downstream authorization meaning must be correct. -- Never hardcode keys. Use environment variables in development, a secrets vault (HashiCorp Vault, AWS Secrets Manager) in production. -- All keys are versioned. See [§11.4](#114-key-rotation-procedure) for the rotation procedure. +--- -### 8.5 Known Tradeoff: Revocation +## 7. Security Requirements -All stateless token systems share a fundamental constraint: a stolen cookie is valid until expiry regardless of encryption. Encryption on Cookie 2 protects against the **legitimate user** extracting raw token values — it does not protect against physical cookie theft (device compromise). The 15-minute expiry is the primary mitigation. +### 7.1 Browser and Token Handling -If **immediate per-token revocation** is required, a minimal server-side denylist must be introduced. This is a deliberate architectural decision and should be made explicitly if the threat model requires it. It is out of scope for the current stateless design. +- The frontend reads only part-1 cookies. It sends access-token part 1 only to the trusted BFF origin and sends refresh-token part 1 only to `/auth/refresh`. +- The BFF accepts access-token part 1 only in the partial Bearer header and refresh-token part 1 only in the refresh request body. Complete browser-supplied tokens are rejected. +- Access/refresh part-2 cookies are `HttpOnly`; both ID-token parts are intentionally JavaScript-readable for profile display. All production token cookies are `Secure`, host-only, and use an explicit `SameSite` policy. +- Do not put token halves, reconstructed tokens, authorization codes, or client secrets in URLs, application logs, analytics events, error messages, or telemetry attributes. +- Apply strict maximum sizes to headers, cookies, individual token parts, and reconstructed tokens. +- Use HTTPS in staging and production for both portal-to-BFF and BFF-to-IdP traffic. +- Keep access-token, refresh-token, ID-token, and cookie lifetimes aligned with IdP policy; cookies must not extend token validity. -### 8.6 Provider Token Assumptions (Header-Trust Backend Mode) +### 7.2 JWT/JWKS Safety -In this deployment mode, OpenFGC does not verify bearer tokens and trusts upstream headers from the BFF. +- Pin the expected issuer and audience; do not infer either from untrusted token claims. +- Permit only explicitly configured asymmetric signing algorithms. +- Use the issuer's discovered JWKS URI; do not accept a JWKS URL from the token. +- Bound JWKS cache lifetime and apply HTTP timeouts. Retain and use the last known-good keys during transient discovery/JWKS retrieval failures, provided they still validate the token; do not discard a usable cache merely because refresh failed. +- Refresh JWKS on an unknown `kid` before rejecting a token, supporting normal IdP key rotation. +- Fail closed if discovery/JWKS integrity cannot be established and no valid cached key can validate the token. -Required provider capabilities: -- refresh token issuance for this client (commonly via `offline_access`) -- ID token at login callback (for identity bootstrap) -- refresh flow that returns either a verifiable `id_token` or an access token usable with UserInfo +### 7.3 Cross-Site Request and XSS Protection -JWT access tokens are recommended but not mandatory for proxying in this mode, because the BFF does not forward bearer tokens to OpenFGC. +The design does not issue a separate API CSRF cookie or token. Protected API calls and logout require a JavaScript-created partial Bearer header plus the matching HTTP-only access-token half. Refresh requires refresh-token part 1 in the explicit request body plus the matching HTTP-only part-2 cookie. Because another origin cannot read either JavaScript-readable part through the same-origin policy, automatically attached cookies alone cannot authenticate these operations. Login/callback correlation uses a separate one-time OAuth state cookie and S256 PKCE verifier cookie; OIDC nonce remains deferred. -**Confirm before implementation:** In your IdP admin console, verify refresh token issuance, ID token validation parameters (`iss`/`aud`), and (if required) RP-initiated logout endpoint availability. +The split-token model reduces complete-token exfiltration but does not prevent same-origin action by injected scripts. The React portal must maintain a restrictive CSP, avoid unsafe HTML injection, review third-party scripts, and apply normal output-encoding and dependency controls. -### 8.7 Security Headers +### 7.4 Security Headers and Caching -Applied by `SecurityHeadersMiddleware` on all responses: +Apply to BFF responses: ``` Content-Security-Policy: default-src 'none' -X-Content-Type-Options: nosniff -X-Frame-Options: DENY -Referrer-Policy: strict-origin-when-cross-origin +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +Referrer-Policy: strict-origin-when-cross-origin +Cache-Control: no-store +Pragma: no-cache ``` -Caching policy (required on auth/session-sensitive responses): +The BFF returns JSON and redirects only; the React portal has its own CSP. -``` -Cache-Control: no-store -Pragma: no-cache -``` +### 7.5 Frontend XSS Hardening -For CORS responses, include: +The static-compatible hardening baseline is implemented as part of the authentication boundary. The production build emits an enforcing CSP meta policy plus a `_headers` deployment artifact; the static host must apply the emitted HTTP headers so header-only directives such as `frame-ancestors` are enforced. -``` -Vary: Origin +The split-token design limits complete-token exfiltration but JavaScript executing in the portal origin can still read every part-1 cookie and issue authenticated requests while the browser supplies part 2. Frontend XSS prevention is therefore part of the authentication boundary, not only a UI concern. + +Serve the React application with a restrictive, deployment-specific CSP. All application JavaScript is emitted as external same-origin build assets, and inline scripts, inline event handlers, string-to-code execution, and `'unsafe-eval'` remain prohibited. Oxygen UI currently uses Emotion-generated runtime style elements, so styles retain a narrowly documented `style-src 'unsafe-inline'` exception until per-response style nonces or a build-time styling migration is implemented. A production baseline is: + +```text +Content-Security-Policy: + default-src 'self'; + script-src 'self'; + style-src 'self' 'unsafe-inline'; # temporary Oxygen UI/Emotion exception + connect-src 'self' ; + img-src 'self' data:; + font-src 'self'; + object-src 'none'; + base-uri 'none'; + frame-ancestors 'none'; + form-action 'self' ; + manifest-src 'self'; + upgrade-insecure-requests ``` -`default-src 'none'` is safe for the BFF because it returns only JSON and redirects — no scripts, styles, or media. The portal's CSP (where content policy is more nuanced) is defined separately and is out of scope for this plan. +The production HTML must not contain inline scripts, inline event handlers, inline style attributes, or inline style elements. Runtime Emotion style elements are the only current exception and are covered by the temporary style policy above; no script nonce/hash handling is introduced. Keep development-only script allowances out of production configuration. Introduce future CSP changes in report-only mode when necessary, review violations, and then enforce; reporting payloads must not include token or user data. -### 8.8 Session Cookie Size Note +Additionally: -The BFF intentionally does not persist `id_token` in `session_token`. This is a size-safety decision to avoid browser/proxy cookie limit failures. RP-initiated logout is still attempted via `end_session_endpoint` without `id_token_hint`. +- Do not use `dangerouslySetInnerHTML`, direct `innerHTML`, `document.write`, string-to-code APIs, or dynamic script construction. When rendering trusted rich-text is an explicit requirement, sanitize it with an allowlist-based, centrally configured sanitizer and test bypass payloads. +- Prefer text rendering and framework escaping for all IdP claims, API values, URLs, and error messages. Treat ID-token profile claims as untrusted display input even though the token signature was validated. +- Do not copy token parts into `localStorage`, `sessionStorage`, application state snapshots, Redux/devtools persistence, analytics, or error-reporting context. +- Self-host scripts where practical. Pin third-party dependencies and use Subresource Integrity plus `crossorigin` for any explicitly approved external static script or stylesheet. +- Restrict URL/navigation sinks to validated relative paths or explicit origin allowlists. Never insert token claims or API data into executable URLs. +- Consider enforcing Trusted Types where supported after removing incompatible sinks. +- Run dependency vulnerability checks and targeted DOM-XSS tests in CI, and verify production CSP/security headers through integration tests. --- -## 9. Project Structure +## 8. Project Structure ``` portal/backend/ -├── cmd/ -│ └── server/ -│ ├── main.go # Entry point, router setup -│ └── servicemanager.go # Service and middleware initialisation -│ +├── cmd/server/ +│ ├── main.go +│ └── servicemanager.go ├── internal/ -│ ├── auth/ -│ │ ├── handler.go # /auth route handlers -│ │ ├── oidc.go # OIDC provider client, ExchangeCodeForToken, RefreshAccessToken -│ │ ├── identity_token.go # Cookie 1: GenerateAuthToken, ValidateAuthToken -│ │ ├── session_token.go # Cookie 2: GenerateSessionToken, DecryptSessionToken -│ │ ├── csrf.go # GenerateCSRFToken, ValidateCSRFToken -│ │ ├── state.go # GenerateState, ValidateState -│ │ └── service.go # Auth business logic, cookie issuance -│ │ +│ ├── me/ +│ │ ├── init.go # module initialization and /me route registration +│ │ ├── handler.go # /me request handlers +│ │ └── service.go # self-service consent operations │ ├── proxy/ -│ │ ├── handler.go # Proxy route handler -│ │ └── service.go # ProxyToOpenFGC -│ │ -│ ├── middleware/ -│ │ ├── auth.go # Cookie 1 validation -│ │ ├── session.go # Cookie 2 decryption, key selection -│ │ ├── csrf.go # CSRF double-submit validation -│ │ ├── cors.go # CORS — explicit origin, credentials -│ │ ├── security_headers.go # CSP, X-Frame-Options, etc. -│ │ └── logger.go # Request / response logging -│ │ -│ ├── config/ -│ │ └── config.go # Env config, key loading, validation -│ │ -│ └── model/ -│ └── types.go # IdentityClaims, SessionClaims, OIDCTokenResponse -│ +│ │ ├── init.go # module initialization and /api route registration +│ │ ├── handler.go # proxy HTTP handler +│ │ ├── service.go # OpenFGC request transformation/proxying +│ │ └── service_test.go +│ └── system/ +│ ├── auth/ +│ │ ├── oidc_client.go # authorization URL and confidential-client token exchange +│ │ ├── cookies.go # split, issue, reconstruct, rotate, and clear token cookies +│ │ ├── handlers.go # login, callback, refresh, and logout endpoints +│ │ ├── validator.go # issuer discovery, JWKS cache, ID/access JWT validation +│ │ ├── principal.go # validated claim-to-Principal mapping +│ │ ├── scopes.go # canonical typed portal scope names and known-scope set +│ │ └── policy.go # scope, organization, and ownership helpers +│ ├── config/ +│ │ ├── config.go # configuration loading, defaults, validation +│ │ └── config_test.go +│ ├── context/ +│ │ └── identity.go # request-scoped Principal and UserIdentity accessors +│ ├── correlation/ +│ │ └── id.go # correlation-ID helpers +│ ├── healthcheck/ +│ │ └── handler.go +│ ├── middleware/ +│ │ ├── split_token_auth.go # reconstructs access token, validates it, stores Principal +│ │ ├── correlationid.go +│ │ ├── correlationid_test.go +│ │ ├── cors.go +│ │ ├── cors_test.go +│ │ ├── identity.go # central UserIdentity resolver: token claims or test placeholders +│ │ └── identity_test.go +│ └── log/ +│ ├── log.go +│ └── log_test.go ├── tests/ -│ ├── unit/ -│ │ ├── identity_token_test.go -│ │ ├── session_token_test.go -│ │ ├── csrf_test.go -│ │ └── state_test.go │ └── integration/ -│ └── auth_test.go -│ -├── docs/ -│ ├── DEPLOYMENT.md # Rate limiting, race condition, key rotation -│ └── API.md # Endpoint reference -│ -├── .env.example +│ ├── health_test.go +│ ├── proxy_invalid_consent_id_test.go +│ ├── proxy_phase2_test.go +│ └── server_test.go +├── openapi/ +│ └── bff.yaml +├── README.md +├── Taskfile.yml ├── go.mod ├── Dockerfile ├── docker-compose.yml -└── Makefile +└── go.sum ``` ---- - -## 10. Configuration - -Configuration implementation note: -- Use `Koanf` as the configuration loader/merger for the BFF. -- Keep environment variables as the primary runtime source. -- Support file-backed configuration for local/dev overlays as needed. +Authentication is added within the existing `internal/system` foundation. `internal/system/auth` owns confidential-client token exchange, split-token cookies, refresh/logout flows, provider discovery, JWKS/JWT validation, normalized principal creation, the canonical scope registry, and authorization middleware. It reconstructs the access token, invokes validation, discards the raw token, and stores only the validated principal (`UserID`, `OrgID`, and scopes) in `internal/system/context`. The same middleware creates that principal from explicit non-production placeholders when placeholder mode is enabled. -```bash -# ── Server ──────────────────────────────────────────────────────────────── -PORT=8080 -ENV=development -LOG_LEVEL=info - -# ── OIDC Provider ───────────────────────────────────────────────────────── -OIDC_ISSUER_URL=https://localhost:9443 -OIDC_CLIENT_ID=bff_client -OIDC_CLIENT_SECRET=your_client_secret -OIDC_REDIRECT_URI=http://localhost:8080/auth/callback -OIDC_SCOPE=openid profile email offline_access -OIDC_RESOURCE_AUDIENCE=openfgc-api # optional in header-trust backend mode; keep when provider policy requires audience-bound tokens -# If `offline_access` is removed, disable `/auth/refresh` and related portal refresh flow. - -# ── Cookie 1: auth_token (JWS, RS256) ──────────────────────────────────── -JWT_RSA_PRIVATE_KEY_PATH=./keys/rsa_private.pem -JWT_RSA_PUBLIC_KEYS={"1":"./keys/rsa_public_v1.pem","2":"./keys/rsa_public_v2.pem"} -JWT_KEY_VERSION=2 # active signing key version (written to JWT kid) -JWT_EXPIRY_MINUTES=15 - -# ── Cookie 2: session_token (JWE, AES-256-GCM) ─────────────────────────── -# JSON map of version → hex-encoded 32-byte key -# Retain old versions during rotation (see DEPLOYMENT.md) -JWE_KEYS={"1":"","2":""} -JWE_ACTIVE_KEY_VERSION=2 -SESSION_EXPIRY_MINUTES=15 -SESSION_COOKIE_MAX_BYTES=3500 # hard cap for full serialized Set-Cookie length - -# ── CSRF ────────────────────────────────────────────────────────────────── -CSRF_HMAC_KEY=<32-byte-hex-encoded-key> - -# ── OIDC State ──────────────────────────────────────────────────────────── -STATE_ENC_KEY=<32-byte-hex-encoded-key> -STATE_MAX_AGE_MINUTES=10 - -# ── OpenFGC Backend ─────────────────────────────────────────────────────── -OPENFGC_API_URL=http://localhost:9090 -OPENFGC_API_TIMEOUT=10s - -# ── CORS ────────────────────────────────────────────────────────────────── -PORTAL_ALLOWED_ORIGINS=http://localhost:3000 # comma-separated allowlist; never wildcard - -# ── Cookies ─────────────────────────────────────────────────────────────── -AUTH_COOKIE_NAME=auth_token # prod: __Host-auth_token -SESSION_COOKIE_NAME=session_token # prod: __Host-session_token -CSRF_COOKIE_NAME=csrf_token # prod: __Host-csrf_token -COOKIE_DOMAIN= # localhost dev: leave empty (host-only). prod: set real domain - # when using __Host-* cookie names, this MUST stay empty -COOKIE_PATH=/ # required so cookies set on /auth are also sent to /api -COOKIE_SECURE=true # default -# Local HTTP-only dev override: -# COOKIE_SECURE=false # use ONLY with http://localhost in development -# Never use false in staging/production. -COOKIE_SAME_SITE=Strict - -# Refresh accepts slightly expired auth_token only within this grace window -REFRESH_AUTH_GRACE_SECONDS=300 - -# ── OIDC Refresh Token Rotation ─────────────────────────────────────────── -ENABLE_REFRESH_TOKEN_ROTATION=true -``` +Add unit tests beside the package under test and extend `tests/integration` for end-to-end OIDC, split-token authentication, and proxy scenarios. --- -## 11. Deployment Notes +## 9. Configuration -### 11.1 Rate Limiting +Use Koanf with environment variables as the primary runtime source and optional file overlays for local development. -Rate limiting on auth endpoints (`/auth/login`, `/auth/callback`, `/auth/refresh`, `/auth/logout`) is an **infrastructure concern** and must be enforced at the gateway or reverse proxy layer before requests reach the BFF. The BFF does not implement rate limiting internally. - -Recommended limits as a starting point: - -| Endpoint | Limit | -|----------|-------| -| `/auth/login` | 20 req/min per IP | -| `/auth/callback` | 20 req/min per IP | -| `/auth/refresh` | 60 req/min per IP | -| `/auth/logout` | 30 req/min per IP | - -In local development, run Nginx or Traefik in docker-compose in front of the BFF from day one to maintain parity with staging and production. - -### 11.2 Refresh Race Condition - -When provider refresh token rotation is enabled and multiple BFF replicas simultaneously attempt to refresh the same token, the provider will reject all but the first call with `invalid_grant`. The BFF handles this gracefully by clearing cookies and returning 401. The portal must handle 401 by redirecting to `/auth/login`. - -v1 policy is fixed: accept `401` on refresh collision and force re-login (no distributed lock, no proactive client timer requirement). - -### 11.3 Refresh Endpoint Hardening - -`/auth/refresh` is the highest-risk endpoint in this design because it mints fresh bearer material from a long-lived credential. - -Hard requirements: - -- Keep infrastructure rate limiting enabled for `/auth/refresh` in every environment (including local docker parity) -- Do not implement automatic server-side retry on token refresh failures -- Use deterministic error mapping: - - `invalid_grant` => clear all auth cookies, return 401 - - transient upstream errors/timeouts => return 503 (or 502) without mutating cookies - - malformed/missing session cookie => return 401 - - auth_token signature mismatch or expiry beyond grace window => return 401 -- Log refresh failures with stable reason codes (no token material in logs) -- Portal must avoid silent infinite refresh loops (single refresh attempt per user action/navigation cycle) -- Add integration tests for concurrent refresh calls and repeated 401 handling +```bash +# Server +BFF_SERVER__PORT=8080 +BFF_ENV=development +BFF_LOG__LEVEL=info + +# IdP OIDC confidential client and resource-server validation +BFF_AUTH__ISSUER_URL=https://localhost:9443 +BFF_AUTH__CLIENT_ID=portal-bff +BFF_AUTH__CLIENT_SECRET=replace-with-environment-secret +BFF_AUTH__PORTAL_URL=http://localhost:3000/consents +BFF_AUTH__REDIRECT_URI=http://localhost:8080/auth/callback +BFF_AUTH__POST_LOGOUT_REDIRECT_URI=http://localhost:3000 +BFF_AUTH__SCOPES=openid profile portal:consents:read:self portal:consents:write:self +BFF_AUTH__RESOURCE_AUDIENCE=portal-api +BFF_AUTH__ALLOWED_SIGNING_ALGORITHMS=RS256 +BFF_AUTH__HTTP_TIMEOUT=5s +BFF_AUTH__CLOCK_SKEW=30s +# Disabled unless the IdP emits a reliable access-token-type claim. +BFF_AUTH__REQUIRE_ACCESS_TOKEN_TYPE=false +BFF_AUTH__ACCESS_TOKEN_TYPE_CLAIM=token_type +BFF_AUTH__ACCESS_TOKEN_TYPE_VALUE=access_token + +# Split-token cookies +BFF_AUTH__ACCESS_TOKEN_PART1_COOKIE=portal-at-p1 +BFF_AUTH__ACCESS_TOKEN_PART2_COOKIE=portal-at-p2 +BFF_AUTH__REFRESH_TOKEN_PART1_COOKIE=portal-rt-p1 +BFF_AUTH__REFRESH_TOKEN_PART2_COOKIE=portal-rt-p2 +BFF_AUTH__ID_TOKEN_PART1_COOKIE=portal-id-p1 +BFF_AUTH__ID_TOKEN_PART2_COOKIE=portal-id-p2 +BFF_AUTH__OAUTH_STATE_COOKIE=portal-oauth-state +BFF_AUTH__PKCE_VERIFIER_COOKIE=portal-pkce-verifier +BFF_AUTH__COOKIE_SECURE=false +BFF_AUTH__COOKIE_SAME_SITE=Lax +BFF_AUTH__LOGIN_TRANSACTION_MAX_AGE_SECONDS=600 +BFF_AUTH__MAX_TOKEN_PART_BYTES=3800 +BFF_AUTH__MAX_RECONSTRUCTED_TOKEN_BYTES=7600 +BFF_AUTH__REFRESH_TIMEOUT=10s + +# Claim mapping +BFF_AUTH__SCOPE_CLAIM=scope +BFF_AUTH__ORG_ID_CLAIM=org_id + +# OpenFGC server +BFF_PROXY__OPENFGC_API_URL=http://localhost:8060 +BFF_PROXY__OPENFGC_API_TIMEOUT=10s + +# CORS +BFF_CORS__ALLOWED_ORIGINS=http://localhost:3000 +BFF_CORS__ALLOWED_HEADERS=Authorization,Content-Type,X-Correlation-ID +BFF_CORS__ALLOW_CREDENTIALS=true + +# Proxy safety +BFF_PROXY__MAX_REQUEST_BYTES=1048576 + +# Test/local identity only — startup must reject this in production +BFF_PROXY__PLACEHOLDER_MODE_ENABLED=false +BFF_PROXY__PLACEHOLDER_USER_ID= +BFF_PROXY__PLACEHOLDER_ORG_ID= +``` + +When placeholder mode is enabled in an allowed non-production environment, both `BFF_PROXY__PLACEHOLDER_USER_ID` and `BFF_PROXY__PLACEHOLDER_ORG_ID` are required and must be non-empty. Placeholder mode remains invalid in production. + +The OIDC client secret must come from a secret manager or mounted secret file, not committed configuration or ordinary environment dumps. Validate redirect URIs, cookie settings, allowed origins, issuer, audience, and placeholder-mode restrictions at startup. The BFF owns no transaction-encryption or JWT-signing keys; the IdP remains the token issuer. + +The React application has no IdP client configuration or IdP SDK. Its portal auth client contains only BFF-relative auth routes, readable part-1 cookie names, split-token request transport, and the one-refresh/one-retry behavior. -### 11.4 Key Rotation Procedure +--- -The `kid` field in the JWE header allows the BFF to select the correct decryption key from a versioned map. This enables graceful rotation: old cookies remain decryptable during the transition window, and are transparently reissued with the new key. +## 10. Deployment Notes -**Rotation steps (JWE / AES key):** +### 10.1 Network Boundary -``` -Step 1 — Add new key, keep old key, bump active version - JWE_KEYS={"1":"","2":""} - JWE_ACTIVE_KEY_VERSION=2 - Deploy. New cookies use key version 2. Old cookies (version 1) still decrypt. - Session middleware reissues version-1 cookies with version-2 on every response. - -Step 2 — Wait one TTL window (15 minutes) - All active sessions now carry version-2 cookies. - -Step 3 — Remove old key - JWE_KEYS={"2":""} - Deploy. Any remaining version-1 cookies return ErrUnknownKeyVersion → 401 → re-auth. -``` +- Expose the React portal and BFF through the intended ingress only. +- Do not expose OpenFGC port `8060` to browsers or public networks. +- Permit inbound OpenFGC traffic only from the BFF/private network identity. +- Strip trusted context headers at ingress so only the BFF can set them. +- Prefer mTLS or equivalent service-to-service authentication between BFF and OpenFGC. -**Rotation steps (RSA keypair / JWT signing key):** +### 10.2 Availability and Rate Limiting -``` -Step 1 — Generate new RSA keypair -Step 2 — Add new public key to JWKS endpoint alongside old public key - Both old and new tokens verify correctly during transition. -Step 3 — Update JWT_RSA_PRIVATE_KEY_PATH to new private key, bump JWT_KEY_VERSION - New auth_token cookies signed with new key. Old cookies verify via old public key. -Step 4 — Wait one TTL window (15 minutes) -Step 5 — Remove old public key from JWKS endpoint -``` - -### 11.5 IdP TLS in Local Development +- Rate-limit protected API routes and malformed/failed-authentication requests at the gateway or reverse proxy. +- Apply stricter limits to expensive mutation endpoints. +- Configure timeouts for IdP discovery/JWKS and OpenFGC calls. +- Monitor JWKS refresh failures, authentication failure reason codes, authorization denials, and OpenFGC latency without recording token material. -If the selected IdP uses a self-signed certificate in local development, TLS verification will fail in the BFF container. Resolution options: +### 10.3 Key Rotation -```yaml -# docker-compose.yml — mount IdP cert into BFF container -services: - bff: - volumes: - - ./certs/idp.crt:/certs/idp.crt:ro - environment: - SSL_CERT_FILE: /certs/idp.crt -``` +The IdP owns signing-key rotation. The BFF supports it by respecting JWKS cache lifetime and refreshing JWKS when it encounters an unknown `kid`. The BFF requires an operational rotation procedure for its OIDC client secret but owns no transaction-encryption or JWT-signing keys. -Do **not** use `InsecureSkipVerify: true` in any environment other than local development, and never commit it without an explicit warning comment. +### 10.4 Local Development -### 11.6 Header-Trust Backend Boundary (Mandatory) +- Portal: `http://localhost:3000` +- BFF: `http://localhost:8080` +- IdP: `https://localhost:9443` +- OpenFGC: `http://localhost:8060` -When OpenFGC trusts upstream headers and does not verify bearer tokens, network boundary controls are mandatory: +Trust the local IdP certificate through the container/host trust store. Do not use `InsecureSkipVerify` outside an explicitly isolated local-development setup. -- OpenFGC must not be directly reachable from browsers/public networks -- Allow inbound access to OpenFGC only from BFF (private network ACL / security group) -- At ingress, strip any client-supplied `org-id` and `TPP-client-id`; only BFF may set them -- Prefer mTLS between BFF and OpenFGC, or equivalent service-to-service authentication at the edge -- Deny or drop requests that bypass BFF path mapping/policy enforcement +Local HTTP development may use non-`__Host-` cookie names with `Secure=false` through explicit development-only configuration. Startup must reject insecure authentication cookies in staging/production. --- -## 12. Implementation Checklist - -### Setup -- [ ] Initialise Go 1.22+ project, confirm stdlib mux path parameter support -- [ ] Confirm IdP token format is JWT and refresh tokens are enabled (see [§8.6](#86-provider-token-format-assumption)) -- [ ] Generate RSA keypair for auth_token (Cookie 1) -- [ ] Generate AES-256 key(s) for session_token (Cookie 2) -- [ ] Generate HMAC key for CSRF tokens -- [ ] Generate encryption key for OIDC state -- [ ] Set cookie policy with `Path=/` for auth_token, session_token, and csrf_token -- [ ] Configure `AUTH_COOKIE_NAME`, `SESSION_COOKIE_NAME`, and `CSRF_COOKIE_NAME` per environment -- [ ] Mount IdP TLS cert in docker-compose (see [§11.5](#115-idp-tls-in-local-development)) - -### Authentication -- [ ] `GeneratePKCE` + OIDC `nonce` generation on login -- [ ] `GenerateState` / `ValidateState` — encrypted + authenticated, timestamp-verified, carries PKCE verifier + nonce -- [ ] `/auth/login` — generate PKCE + nonce + state, redirect to OIDC provider with `code_challenge_method=S256` -- [ ] `/auth/me` — validate `auth_token` and return identity for portal bootstrap (200/401) -- [ ] `/auth/callback` — validate state, exchange code with `code_verifier`, fully validate ID token (signature/JWKS, `iss`, `aud`, `exp`/`iat`/`nbf`, nonce), issue all three cookies -- [ ] Do not persist `id_token` in `session_token` (size guard). Use `end_session_endpoint` without `id_token_hint` -- [ ] `/auth/logout` — POST + same-origin allowlist (`Origin`/`Referer`) validation, clear all cookies, call provider end-session endpoint when available -- [ ] `/auth/refresh` — require CSRF + session_token + signed auth_token consistency; allow auth_token expiry only within a short grace window - -### Token Functions -- [ ] `GenerateAuthToken` / `ValidateAuthToken` (JWS, RS256, JWT `kid` + public-key-set lookup) -- [ ] `GenerateSessionToken` / `DecryptSessionToken` (JWE, AES-256-GCM, kid-versioned, stores refresh/session material only) -- [ ] Enforce `SESSION_COOKIE_MAX_BYTES` before writing `session_token`; fail closed on overflow -- [ ] `GenerateCSRFToken` / `ValidateCSRFToken` (HMAC over `sub`+`iat`, double-submit equality, constant-time verification) -- [ ] `GeneratePKCE` / `ValidateIDToken` -- [ ] On refresh, enforce identity re-validation (validate returned `id_token` or UserInfo `sub` consistency) before reissuing auth cookies -- [ ] `LoadKeyMap` / `ActiveKey` (versioned key management) -- [ ] Reissue CSRF token whenever `auth_token` is reissued (because CSRF HMAC binds to auth `sub` + `iat`) - -### Middleware -- [ ] `SecurityHeadersMiddleware` — CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy -- [ ] `CORSMiddleware` — origin allowlist matching, credentials: true, OPTIONS preflight -- [ ] `AuthMiddleware` — Cookie 1 RS256 verification -- [ ] `SessionMiddleware` — Cookie 2 AES-GCM decryption, kid-based key selection, transparent reissue -- [ ] `CSRFMiddleware` — HMAC-bound double-submit validation for all non-safe methods (`GET`, `HEAD`, `OPTIONS`, `TRACE` exempt) -- [ ] `OriginCheckMiddleware` — strict same-origin allowlist validation for `POST /auth/logout` -- [ ] `LoggerMiddleware` -- [ ] Logout route middleware chain — `OriginCheck` for `POST /auth/logout` -- [ ] Refresh route middleware chain — `Session` → `CSRF` + mandatory auth_token sub/signature consistency for `POST /auth/refresh` -- [ ] Authorization scoping middleware/policy — enforce user-scoped filters on `/me/*` routes using authenticated principal (`sub`) -- [ ] Top-level router wrapper — apply middleware in canonical order: `SecurityHeaders` → `CORS` → `Logger` -- [ ] Router structure — public auth routes on base mux, refresh on dedicated protected mux, `/api/*` on full protected mux (see [§6.1](#61-router-structure)) -- [ ] Keep route wiring lean via one `RegisterRoutes(...)` entry point and middleware-chain tests - -### Proxy & Testing -- [ ] `ProxyToOpenFGC` — clone request, map path `/api/*` to `/api/v1/*`, and inject trusted upstream headers -- [ ] Proxy hardening — hop-by-hop header stripping, ignore client-supplied tenant/client headers, correlation-id propagation, request size limits, timeout/retry policy, method/path allowlisting -- [ ] Implement `GET /me/consents` → upstream `/api/v1/consents?userIds=` mapping -- [ ] Unit tests: identity_token, session_token, csrf, state -- [ ] Unit tests: middleware-chain assertions -- [ ] Integration tests: full auth flow (login → callback → authenticated request → refresh → logout) -- [ ] Integration tests: proxy header transforms (`org-id`, `TPP-client-id`, `X-Correlation-ID`) and client-header override prevention -- [ ] Integration tests: proxy path mapping `/api/*` -> `/api/v1/*` with query-string preservation -- [ ] Integration tests: `/me/consents` always injects authenticated `userIds` and ignores client-supplied user filters -- [ ] Integration tests: refresh failure mapping (`invalid_grant` -> 401 + cookie clear, upstream timeout -> 503 without cookie mutation) -- [ ] Integration tests: refresh collision behavior (`invalid_grant` path -> 401 + re-login) -- [ ] Integration tests: logout same-origin allowlist algorithm (`Origin`/`Referer` in allowlist accepted, absent/mismatch rejected) -- [ ] Integration tests: cache-control and CORS `Vary: Origin` headers on auth endpoints -- [ ] Docker + docker-compose with Nginx/Traefik for rate limiting parity -- [ ] Deployment controls: OpenFGC private-only exposure (BFF-origin traffic only), client header stripping at edge, and optional mTLS between BFF and OpenFGC - -### Documentation -- [ ] `docs/DEPLOYMENT.md` — rate limiting requirements, v1 refresh race policy (401 -> re-login), key rotation procedure -- [ ] `docs/API.md` — endpoint reference -- [ ] `.env.example` — all variables with descriptions +## 11. Implementation Checklist + +### React Portal + +- [x] Implement the portal-owned auth client with login, logout, refresh, local ID-token profile decoding, and signed-in status helpers; do not add an IdP SDK. +- [x] Add an API interceptor that reads access-token part 1 and sets `Authorization: Bearer ` with credentials enabled. +- [x] Send refresh-token part 1 only in the `/auth/refresh` request body; never make cookie-only refresh requests. +- [x] Handle `401` with at most one refresh and one request retry before starting login. +- [x] Handle `403` as an authorization failure. +- [x] Reconstruct and decode the ID-token cookies only for profile display; never send browser-decoded claims back as trusted identity or use them for authorization. +- [x] Apply the static-compatible restrictive frontend CSP, prohibit unsafe script execution, and verify that frontend storage and production source sinks do not capture token parts. The temporary Emotion style exception remains tracked below. + +### BFF Authentication and Authorization + +- [x] Implement OIDC confidential-client discovery, authorization URL construction, code exchange, and refresh-token exchange. +- [x] Generate one-time OAuth state and S256 PKCE transactions, store state/verifier only in short-lived HTTP-only cookies, validate state in constant time, consume transactions before exchange, and reject callback replay without server-side session or key storage. +- [x] Implement split-token cookie issuance, reconstruction, rotation, exact-attribute clearing, and size limits. +- [x] Implement strict partial Bearer-header and part-2-cookie parsing. +- [x] Implement issuer discovery and library-managed remote JWKS caching through `go-oidc`, with bounded HTTP timeouts, cached-key reuse, and unknown-`kid` refresh for normal key rotation. +- [x] Validate ID tokens at callback, including signature, allowed algorithm, issuer, client audience, and time claims; nonce validation is deferred. +- [x] Validate reconstructed access tokens, including signature, allowed algorithm, issuer, resource audience, time claims, and subject; validate access-token type only when explicitly enabled and supported by the IdP. +- [x] Refresh JWKS on unknown `kid` and support normal key rotation. +- [x] Map validated claims to the normalized `Principal` model. +- [ ] Define all portal scope names as typed constants and an immutable known-scope set in `internal/system/auth/scopes.go`; remove duplicated production scope literals. +- [x] Validate configured requested portal scopes against the canonical scope registry during startup. +- [x] Implement `/auth/login`, `/auth/callback`, `/auth/refresh`, and `/auth/logout` with generic errors and token redaction. +- [ ] Serialize or otherwise make concurrent refresh requests safe for refresh-token rotation. +- [x] Reject cookie-only refresh/logout requests: refresh requires refresh-token part 1 in the body, while logout requires the partial Bearer header and matching access-token part-2 cookie. +- [x] Store the validated `Principal` containing user ID, organization ID, and scopes in request context, including equivalent placeholder-mode identity. +- [x] Retain explicit placeholder identity mode only for test/local use; reject it in production and never use it as an authentication fallback. +- [x] Configure and enforce the §4 scope, organization, and ownership policy for both `/me/*` and `/api/*` routes. +- [x] Return `401` with `WWW-Authenticate: Bearer` for authentication failures and `403` without a bearer challenge for authorization failures; cover both behaviors in unit and integration tests. + +### BFF Routes and Proxy + +- [x] Enforce self-scoping and object ownership on `/me/*` routes; reuse only a fetched, owned consent's group ID for the corresponding upstream mutation. +- [x] Enforce the §4 scope-to-route policy for the existing controlled `/api/*` passthrough route. +- [x] Update `openapi/portal-backend.yaml`: document auth endpoints and define the documentation-only `PortalOAuthDocumentation` scheme plus `AccessTokenPart2` as an AND security requirement. Document required route scopes explicitly, explain the non-standard browser-facing split-token contract, and contract-test documented scopes against `scopes.go` route policies. +- [x] Maintain explicit method/path mappings to OpenFGC `/api/v1/*`. +- [x] Point `BFF_PROXY__OPENFGC_API_URL` to port `8060`. +- [x] Strip client-supplied trusted headers; inject user and organization context only from `UserIdentity`. +- [x] Remove generic placeholder/token-derived `group-id` injection; preserve only consent-bound group reuse for self-service mutations. +- [x] Apply body limits, timeouts, correlation IDs, and hop-by-hop header stripping. + +### Security and Operations + +- [x] Configure exact CORS origins, permit the partial `Authorization` header, and enable credentials only for those origins. +- [x] Enforce secure host-only production cookies with explicit `SameSite`, correct expiry, HTTP-only access/refresh part-2 cookies, and intentionally JavaScript-readable ID-token parts. +- [ ] Store the OIDC client secret in the deployment secret manager and document rotation; do not introduce BFF-owned transaction or JWT-signing keys. +- [ ] Apply security and no-store cache headers. +- [x] Ensure token halves, reconstructed tokens, codes, and client secrets are redacted from application, proxy, and observability logs. +- [ ] Make OpenFGC port `8060` private-only and enforce BFF-only access. +- [ ] Add infrastructure rate limiting and monitoring for failed authentication/JWKS availability. + +### Frontend XSS Hardening + +- [x] Define a production React CSP with an exact BFF `connect-src`, prohibit inline/eval scripts, embed the enforceable meta subset, and emit the complete policy for the static host as `dist/_headers`. +- [x] Emit JavaScript and CSS build assets from the same origin and verify that production HTML contains no inline scripts, event handlers, style attributes, or style elements. +- [x] Set `object-src 'none'`, `base-uri 'none'`, `frame-ancestors 'none'`, constrained `form-action`, and `upgrade-insecure-requests` for HTTPS production builds. +- [x] Reject unsafe DOM/code sinks and web-storage use during production build verification; no trusted rich-text rendering currently requires a sanitizer. +- [x] Treat decoded ID-token claims and API content as untrusted display input, rely on React escaping, and test markup-shaped profile claims. +- [x] Verify that token parts never enter web storage or persisted frontend state. No analytics or frontend error-reporting integration is present. +- [x] Self-host frontend scripts and styles and pin direct dependencies. No external static script or stylesheet currently requires Subresource Integrity. +- [x] Add CSP policy tests, DOM-XSS rendering tests, production HTML/source-sink verification, and high-severity production dependency auditing to CI. +- [ ] Replace the temporary `style-src 'unsafe-inline'` exception required by Oxygen UI/Emotion with per-response style nonces or a reviewed build-time styling migration. Test dynamic MUI/Oxygen components before enforcing the replacement. +- [ ] Add sanitized CSP violation reporting when an approved reporting endpoint and data-retention policy are available; reports must exclude token and user data. + +### Testing + +- [x] Unit tests: token splitting/reconstruction, missing or duplicate halves, wrong half order, malformed partial Bearer header, cookie attributes, exact clearing, and token/cookie size limits. +- [x] Unit tests: callback errors and token-endpoint error mapping. +- [x] Unit tests: OAuth state generation and constant-time matching, RFC 7636 S256 challenge generation, PKCE verifier validation, transaction-cookie attributes/consumption, missing/mismatched/duplicate state, and replay rejection. +- [x] Unit tests: valid reconstructed token, invalid signature, unsupported algorithm, expired/not-yet-valid token, wrong issuer, wrong audience, missing subject, and wrong token type. +- [x] Unit tests: ID-token signature, issuer, audience, and time validation; nonce tests are deferred. +- [x] Unit tests: JWKS cache hits, unknown-`kid` refresh, rotation, and transient JWKS failure behavior. +- [x] Unit tests: canonical scope uniqueness, configured-scope validation, route-policy references, and OpenAPI scope parity. +- [x] Unit tests: claim mapping, scope policy, organization policy, and ownership policy. +- [x] Integration tests: login/callback cookie issuance and portal-style partial Bearer plus HTTP-only-cookie authentication across every protected route policy. +- [x] Integration tests: missing part 1, missing part 2, complete-token injection, duplicate cookies, and tampered reconstruction all return `401`. +- [x] Integration tests: successful refresh, rotated and retained refresh tokens, expired/invalid refresh, concurrent non-rotating refresh, and atomic cookie replacement. Refresh-token rotation race serialization remains explicitly deferred. +- [x] Integration tests: cookie-only refresh/logout rejection, logout cookie clearing, and IdP end-session and fallback behavior. +- [x] Integration tests: `401` versus `403` behavior and `WWW-Authenticate` response. +- [x] Integration tests: credentialed CORS preflight and exact `Authorization` header allowance. +- [x] Integration tests: OpenFGC path mapping, query preservation, port-8060 target, and header override prevention. +- [x] Integration tests: `/me/consents` always injects `Principal.UserID` and ignores client user filters; `/api/*` rejects missing `:any` scopes and preserves authorized filters only within `Principal.OrgID`. + +### Deferred Final Hardening + +- [ ] Add OIDC `nonce` generation, one-time cookie storage, ID-token validation, replay tests, and cleanup using the existing stateless login-transaction mechanism. --- -## 13. Patterns, Practices, and Standards - -- HTTP stack: Go stdlib `net/http` with `ServeMux` route registration and middleware composition -- File/module structure: standard Go layout with clear boundaries (`cmd`, `internal`, `tests`, `docs`) and feature-oriented packages for `auth`, `proxy`, `middleware`, and `config` -- Configuration model: centralized configuration loading/validation using `Koanf`, with explicit environment-driven settings for security keys, cookie policy, OIDC, CORS, and upstream targets -- Design approach: contract-first API definitions via OpenAPI, with BFF route and payload contracts maintained from spec -- Dependency approach: stdlib-first implementation style; add third-party libraries only when there is a clear functional need -- Security baseline: AES-256-GCM for encrypted session material, HMAC for CSRF validation, versioned key rotation, and externalized secret management -- Testing model: layered tests with unit coverage for token/middleware helpers and integration coverage for auth, proxying, and security/error paths -- Delivery model: reproducible containerized packaging and local parity environments using `Dockerfile` + `docker-compose`, with scripted build/test workflows -- Operational controls: deployment boundary hardening (header trust boundary, edge stripping rules, private backend exposure) and infrastructure-level rate limiting for auth endpoints +## 12. Standards + +- HTTP stack: Go `net/http` with `ServeMux` and composable middleware. +- Configuration: centralized Koanf loading and startup validation. +- API design: contract-first route and payload definitions; deny-by-default proxy routing. +- Security model: confidential-client OIDC, split-token cross-site request protection, standards-based JWKS/JWT validation, least-privilege scopes, private downstream boundary, and externalized secrets/configuration. +- Testing model: unit tests for split cookies, reconstruction, state/PKCE correlation, JWT validation, and policy logic plus integration tests for the browser-to-BFF auth lifecycle and BFF-to-OpenFGC transformations. OIDC nonce and the remaining CSP items remain deferred hardening work. diff --git a/portal/frontend/.env.example b/portal/frontend/.env.example index a8cf54a8..5fa0389a 100644 --- a/portal/frontend/.env.example +++ b/portal/frontend/.env.example @@ -1 +1,7 @@ VITE_API_BASE_URL=http://localhost:8080 +VITE_AUTH_ENABLED=false +VITE_AUTH_ACCESS_TOKEN_PART1_COOKIE=portal-at-p1 +VITE_AUTH_REFRESH_TOKEN_PART1_COOKIE=portal-rt-p1 +VITE_AUTH_ID_TOKEN_PART1_COOKIE=portal-id-p1 +VITE_AUTH_ID_TOKEN_PART2_COOKIE=portal-id-p2 +VITE_AUTH_LOGOUT_ALLOWED_ORIGINS=https://idp.example.com diff --git a/portal/frontend/README.md b/portal/frontend/README.md index 9149a9a5..4d37b849 100644 --- a/portal/frontend/README.md +++ b/portal/frontend/README.md @@ -49,19 +49,35 @@ pnpm install Create a local `.env` file from `.env.example` before running or building the portal. -| Variable | Description | Example | -| ------------------- | ------------------------------------------------------------------------------------------- | ----------------------- | -| `VITE_API_BASE_URL` | Required base URL for the OpenFGC Portal backend API. Vite embeds this value at build time. | `http://localhost:8080` | +| Variable | Description | Example | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------- | +| `VITE_API_BASE_URL` | Required base URL for the OpenFGC Portal backend API. Vite embeds this value at build time. | `http://localhost:8080` | +| `VITE_AUTH_ENABLED` | Enables frontend authentication gating; set to `true` for protected deployments. | `true` | +| `VITE_AUTH_ACCESS_TOKEN_PART1_COOKIE` | Cookie name for the readable access-token part. | `portal-at-p1` | +| `VITE_AUTH_REFRESH_TOKEN_PART1_COOKIE` | Cookie name for the readable refresh-token part. | `portal-rt-p1` | +| `VITE_AUTH_ID_TOKEN_PART1_COOKIE` | Cookie name for ID-token part 1. | `portal-id-p1` | +| `VITE_AUTH_ID_TOKEN_PART2_COOKIE` | Cookie name for ID-token part 2. | `portal-id-p2` | +| `VITE_AUTH_LOGOUT_ALLOWED_ORIGINS` | Exact comma-separated origins accepted for logout navigation. Include the IdP origin when using its end-session endpoint. | `https://idp.example.com` | + +## Production security headers + +`pnpm build` emits `dist/_headers`; configure the production static host to apply +it and build with the deployment's `VITE_API_BASE_URL`. ## Scripts ```bash +pnpm start pnpm dev pnpm lint +pnpm lint:fix +pnpm format +pnpm format:check pnpm test pnpm test:watch pnpm test:coverage pnpm build +pnpm security:verify pnpm preview ``` diff --git a/portal/frontend/eslint.config.js b/portal/frontend/eslint.config.js index 6623526a..b8eb40d1 100644 --- a/portal/frontend/eslint.config.js +++ b/portal/frontend/eslint.config.js @@ -79,6 +79,19 @@ export default defineConfig([ 'import/no-unresolved': 'off', }, }, + { + files: ['scripts/**/*.mjs'], + languageOptions: { + ecmaVersion: 2023, + sourceType: 'module', + globals: globals.node, + }, + rules: { + 'import/no-extraneous-dependencies': 'off', + 'no-console': 'off', + 'no-restricted-syntax': 'off', + }, + }, { files: ['**/*.test.{ts,tsx}', '**/__tests__/**/*.{ts,tsx}'], rules: { diff --git a/portal/frontend/package.json b/portal/frontend/package.json index 4c53c0d3..844f8bb2 100644 --- a/portal/frontend/package.json +++ b/portal/frontend/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "vite", "dev": "vite", - "build": "tsc -b && vite build", + "build": "tsc -b && vite build && pnpm security:verify", "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "vitest run", @@ -15,43 +15,59 @@ "test:coverage": "vitest run --coverage", "format": "prettier . --write", "format:check": "prettier . --check", + "security:audit": "node scripts/audit-production-dependencies.mjs", + "security:verify": "node scripts/verify-production-security.mjs", "preview": "vite preview" }, "dependencies": { - "@tanstack/react-query": "^5.96.2", - "@wso2/oxygen-ui": "^0.8.0", - "@wso2/oxygen-ui-icons-react": "^0.8.0", - "i18next": "^25.10.5", - "react": "^19.2.0", - "react-dom": "^19.2.0", - "react-i18next": "^16.6.2", - "react-router-dom": "^7.13.2" + "@tanstack/react-query": "5.96.2", + "@wso2/oxygen-ui": "0.8.3", + "@wso2/oxygen-ui-icons-react": "0.8.3", + "i18next": "25.10.5", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-i18next": "16.6.2", + "react-router-dom": "7.18.2" }, "devDependencies": { - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "^9.39.1", - "@testing-library/jest-dom": "^6.9.1", - "@testing-library/react": "^16.3.0", - "@types/node": "^24.10.1", - "@types/react": "^19.2.7", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.1", - "@vitest/coverage-v8": "^3.2.4", - "eslint": "^9.39.1", - "eslint-config-airbnb": "^19.0.4", - "eslint-config-prettier": "^10.1.8", - "eslint-plugin-import": "^2.32.0", - "eslint-plugin-jsx-a11y": "^6.10.2", - "eslint-plugin-prettier": "^5.5.5", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", - "globals": "^16.5.0", - "jsdom": "^26.1.0", - "prettier": "^3.8.1", - "typescript": "~5.9.3", - "typescript-eslint": "^8.48.0", - "vite": "^7.3.1", - "vitest": "^3.2.4" + "@eslint/eslintrc": "3.3.5", + "@eslint/js": "9.39.4", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", + "@types/node": "24.12.0", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "5.2.0", + "@vitest/coverage-v8": "3.2.6", + "eslint": "9.39.4", + "eslint-config-airbnb": "19.0.4", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-import": "2.32.0", + "eslint-plugin-jsx-a11y": "6.10.2", + "eslint-plugin-prettier": "5.5.5", + "eslint-plugin-react": "7.37.5", + "eslint-plugin-react-hooks": "7.0.1", + "eslint-plugin-react-refresh": "0.4.26", + "globals": "16.5.0", + "jsdom": "26.1.0", + "prettier": "3.8.1", + "typescript": "5.9.3", + "typescript-eslint": "8.57.1", + "vite": "7.3.5", + "vitest": "3.2.6" + }, + "pnpm": { + "overrides": { + "yaml@>=1.0.0 <1.10.3": "1.10.3", + "brace-expansion@<1.1.16": "1.1.16", + "brace-expansion@>=2.0.0 <2.1.2": "2.1.2", + "brace-expansion@>=5.0.0 <5.0.8": "5.0.8", + "picomatch@>=4.0.0 <4.0.4": "4.0.4", + "postcss@<8.5.18": "8.5.18", + "esbuild@>=0.27.3 <0.28.1": "0.28.1", + "js-yaml@>=4.0.0 <4.3.0": "4.3.0", + "ws@>=8.0.0 <8.21.0": "8.21.0", + "@babel/core@<=7.29.0": "7.29.6" + } } } diff --git a/portal/frontend/pnpm-lock.yaml b/portal/frontend/pnpm-lock.yaml index bab2edcd..25574e23 100644 --- a/portal/frontend/pnpm-lock.yaml +++ b/portal/frontend/pnpm-lock.yaml @@ -4,115 +4,127 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + yaml@>=1.0.0 <1.10.3: 1.10.3 + brace-expansion@<1.1.16: 1.1.16 + brace-expansion@>=2.0.0 <2.1.2: 2.1.2 + brace-expansion@>=5.0.0 <5.0.8: 5.0.8 + picomatch@>=4.0.0 <4.0.4: 4.0.4 + postcss@<8.5.18: 8.5.18 + esbuild@>=0.27.3 <0.28.1: 0.28.1 + js-yaml@>=4.0.0 <4.3.0: 4.3.0 + ws@>=8.0.0 <8.21.0: 8.21.0 + '@babel/core@<=7.29.0': 7.29.6 + importers: .: dependencies: '@tanstack/react-query': - specifier: ^5.96.2 + specifier: 5.96.2 version: 5.96.2(react@19.2.4) '@wso2/oxygen-ui': - specifier: ^0.8.0 - version: 0.8.3(@mui/system@7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(@wso2/oxygen-ui-icons-react@0.8.3(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 0.8.3 + version: 0.8.3(@mui/system@7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(@wso2/oxygen-ui-icons-react@0.8.3(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@wso2/oxygen-ui-icons-react': - specifier: ^0.8.0 + specifier: 0.8.3 version: 0.8.3(react@19.2.4) i18next: - specifier: ^25.10.5 + specifier: 25.10.5 version: 25.10.5(typescript@5.9.3) react: - specifier: ^19.2.0 + specifier: 19.2.4 version: 19.2.4 react-dom: - specifier: ^19.2.0 + specifier: 19.2.4 version: 19.2.4(react@19.2.4) react-i18next: - specifier: ^16.6.2 + specifier: 16.6.2 version: 16.6.2(i18next@25.10.5(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react-router-dom: - specifier: ^7.13.2 - version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 7.18.2 + version: 7.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) devDependencies: '@eslint/eslintrc': - specifier: ^3.3.5 + specifier: 3.3.5 version: 3.3.5 '@eslint/js': - specifier: ^9.39.1 + specifier: 9.39.4 version: 9.39.4 '@testing-library/jest-dom': - specifier: ^6.9.1 + specifier: 6.9.1 version: 6.9.1 '@testing-library/react': - specifier: ^16.3.0 + specifier: 16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/node': - specifier: ^24.10.1 + specifier: 24.12.0 version: 24.12.0 '@types/react': - specifier: ^19.2.7 + specifier: 19.2.14 version: 19.2.14 '@types/react-dom': - specifier: ^19.2.3 + specifier: 19.2.3 version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': - specifier: ^5.1.1 - version: 5.2.0(vite@7.3.1(@types/node@24.12.0)) + specifier: 5.2.0 + version: 5.2.0(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)) '@vitest/coverage-v8': - specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4(@types/node@24.12.0)(jsdom@26.1.0)) + specifier: 3.2.6 + version: 3.2.6(vitest@3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0)) eslint: - specifier: ^9.39.1 + specifier: 9.39.4 version: 9.39.4 eslint-config-airbnb: - specifier: ^19.0.4 + specifier: 19.0.4 version: 19.0.4(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4))(eslint-plugin-react-hooks@7.0.1(eslint@9.39.4))(eslint-plugin-react@7.37.5(eslint@9.39.4))(eslint@9.39.4) eslint-config-prettier: - specifier: ^10.1.8 + specifier: 10.1.8 version: 10.1.8(eslint@9.39.4) eslint-plugin-import: - specifier: ^2.32.0 + specifier: 2.32.0 version: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) eslint-plugin-jsx-a11y: - specifier: ^6.10.2 + specifier: 6.10.2 version: 6.10.2(eslint@9.39.4) eslint-plugin-prettier: - specifier: ^5.5.5 + specifier: 5.5.5 version: 5.5.5(eslint-config-prettier@10.1.8(eslint@9.39.4))(eslint@9.39.4)(prettier@3.8.1) eslint-plugin-react: - specifier: ^7.37.5 + specifier: 7.37.5 version: 7.37.5(eslint@9.39.4) eslint-plugin-react-hooks: - specifier: ^7.0.1 + specifier: 7.0.1 version: 7.0.1(eslint@9.39.4) eslint-plugin-react-refresh: - specifier: ^0.4.24 + specifier: 0.4.26 version: 0.4.26(eslint@9.39.4) globals: - specifier: ^16.5.0 + specifier: 16.5.0 version: 16.5.0 jsdom: - specifier: ^26.1.0 + specifier: 26.1.0 version: 26.1.0 prettier: - specifier: ^3.8.1 + specifier: 3.8.1 version: 3.8.1 typescript: - specifier: ~5.9.3 + specifier: 5.9.3 version: 5.9.3 typescript-eslint: - specifier: ^8.48.0 + specifier: 8.57.1 version: 8.57.1(eslint@9.39.4)(typescript@5.9.3) vite: - specifier: ^7.3.1 - version: 7.3.1(@types/node@24.12.0) + specifier: 7.3.5 + version: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0) vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/node@24.12.0)(jsdom@26.1.0) + specifier: 3.2.6 + version: 3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0) packages: - '@adobe/css-tools@4.4.4': - resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} '@ampproject/remapping@2.3.0': resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} @@ -121,91 +133,91 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - '@babel/code-frame@7.29.0': - resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': - resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': - resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + '@babel/core@7.29.6': + resolution: {integrity: sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.1': - resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': - resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0 + '@babel/core': 7.29.6 - '@babel/helper-plugin-utils@7.28.6': - resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': - resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.29.2': - resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.2': - resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} engines: {node: '>=6.0.0'} hasBin: true - '@babel/plugin-transform-react-jsx-self@7.27.1': - resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 - '@babel/plugin-transform-react-jsx-source@7.27.1': - resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} engines: {node: '>=6.9.0'} peerDependencies: - '@babel/core': ^7.0.0-0 + '@babel/core': 7.29.6 - '@babel/runtime@7.29.2': - resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': - resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': - resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} '@base-ui-components/utils@0.1.2': @@ -305,158 +317,158 @@ packages: '@emotion/weak-memoize@0.4.0': resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} - '@esbuild/aix-ppc64@0.27.4': - resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.4': - resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.4': - resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.4': - resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.4': - resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.4': - resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.4': - resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.4': - resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.4': - resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.4': - resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.4': - resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.4': - resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.4': - resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.4': - resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.4': - resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.4': - resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.4': - resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.4': - resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.4': - resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.4': - resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.4': - resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.4': - resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.4': - resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.4': - resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.4': - resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.4': - resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -499,18 +511,22 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@floating-ui/utils@0.2.11': - resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} '@fontsource-variable/inter@5.2.8': resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} engines: {node: '>=18.18.0'} - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} engines: {node: '>=18.18.0'} '@humanwhocodes/module-importer@1.0.1': @@ -525,8 +541,8 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} - '@istanbuljs/schema@0.1.3': - resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} engines: {node: '>=8'} '@jridgewell/gen-mapping@0.3.13': @@ -545,8 +561,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@mui/core-downloads-tracker@7.3.9': - resolution: {integrity: sha512-MOkOCTfbMJwLshlBCKJ59V2F/uaLYfmKnN76kksj6jlGUVdI25A9Hzs08m+zjBRdLv+sK7Rqdsefe8X7h/6PCw==} + '@mui/core-downloads-tracker@7.3.11': + resolution: {integrity: sha512-a7I/b/nBTdXYz2cOSlEmkQ9WWE1x8FHpqMhFPp+Y1VPFxcOw91G5ELOHARQAGSPy5V+UCgJua6K/1x70bAtQPw==} '@mui/material@7.3.4': resolution: {integrity: sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==} @@ -568,8 +584,8 @@ packages: '@types/react': optional: true - '@mui/private-theming@7.3.9': - resolution: {integrity: sha512-ErIyRQvsiQEq7Yvcvfw9UDHngaqjMy9P3JDPnRAaKG5qhpl2C4tX/W1S4zJvpu+feihmZJStjIyvnv6KDbIrlw==} + '@mui/private-theming@7.3.11': + resolution: {integrity: sha512-9B+YKms0fRHbNrqp9tOT/DNbNnU5gyvJ1o3qAGXfq8GmZcbJnE3At9x07Zr/o0pkhzg4aDdwXVqe4+AcgtOCPA==} engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -578,8 +594,8 @@ packages: '@types/react': optional: true - '@mui/styled-engine@7.3.9': - resolution: {integrity: sha512-JqujWt5bX4okjUPGpVof/7pvgClqh7HvIbsIBIOOlCh2u3wG/Bwp4+E1bc1dXSwkrkp9WUAoNdI5HEC+5HKvMw==} + '@mui/styled-engine@7.3.10': + resolution: {integrity: sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.4.1 @@ -591,8 +607,8 @@ packages: '@emotion/styled': optional: true - '@mui/system@7.3.9': - resolution: {integrity: sha512-aL1q9am8XpRrSabv9qWf5RHhJICJql34wnrc1nz0MuOglPRYF/liN+c8VqZdTvUn9qg+ZjRVbKf4sJVFfIDtmg==} + '@mui/system@7.3.11': + resolution: {integrity: sha512-7izwGWdNawAKpBKcRlx7f2gFnAAjmASBWvMcyX4YYEeLOFsbfGRbUYGInvnAcUeql3rPxI7F9Ft4oY2OLRz44g==} engines: {node: '>=14.0.0'} peerDependencies: '@emotion/react': ^11.5.0 @@ -615,8 +631,8 @@ packages: '@types/react': optional: true - '@mui/utils@7.3.9': - resolution: {integrity: sha512-U6SdZaGbfb65fqTsH3V5oJdFj9uYwyLE2WVuNvmbggTSDBb8QHrFsqY8BN3taK9t3yJ8/BPHD/kNvLNyjwM7Yw==} + '@mui/utils@7.3.11': + resolution: {integrity: sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==} engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -717,9 +733,9 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@pkgr/core@0.2.9': - resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@pkgr/core@0.3.6': + resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} + engines: {node: ^14.18.0 || >=16.0.0} '@popperjs/core@2.11.8': resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} @@ -727,128 +743,128 @@ packages: '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - '@rollup/rollup-android-arm-eabi@4.59.0': - resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.59.0': - resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.59.0': - resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.59.0': - resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.59.0': - resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.59.0': - resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': - resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.59.0': - resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.59.0': - resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.59.0': - resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.59.0': - resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-loong64-musl@4.59.0': - resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.59.0': - resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-ppc64-musl@4.59.0': - resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.59.0': - resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.59.0': - resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.59.0': - resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.59.0': - resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.59.0': - resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} cpu: [x64] os: [linux] - '@rollup/rollup-openbsd-x64@4.59.0': - resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.59.0': - resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.59.0': - resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.59.0': - resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.59.0': - resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.59.0': - resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} cpu: [x64] os: [win32] @@ -907,8 +923,8 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1003,20 +1019,20 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - '@vitest/coverage-v8@3.2.4': - resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + '@vitest/coverage-v8@3.2.6': + resolution: {integrity: sha512-LsAdmUapA0qSN306d8+zOyawM0hFm2m2Hg9IwVNIKBm+qJV8cijiq2c+gxKZcB1HCfIWAy+0qEZDCUQA58A1cw==} peerDependencies: - '@vitest/browser': 3.2.4 - vitest: 3.2.4 + '@vitest/browser': 3.2.6 + vitest: 3.2.6 peerDependenciesMeta: '@vitest/browser': optional: true - '@vitest/expect@3.2.4': - resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@3.2.6': + resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==} - '@vitest/mocker@3.2.4': - resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + '@vitest/mocker@3.2.6': + resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==} peerDependencies: msw: ^2.4.9 vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 @@ -1026,20 +1042,23 @@ packages: vite: optional: true - '@vitest/pretty-format@3.2.4': - resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@3.2.6': + resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==} + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} - '@vitest/runner@3.2.4': - resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@3.2.6': + resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==} - '@vitest/snapshot@3.2.4': - resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@3.2.6': + resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==} - '@vitest/spy@3.2.4': - resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@3.2.6': + resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==} - '@vitest/utils@3.2.4': - resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@3.2.6': + resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==} '@wso2/oxygen-ui-icons-react@0.8.3': resolution: {integrity: sha512-CPGEaqlM59KJUg51xP43bITbahO3BTxcEvYSZe2O+Lfpd6AyyWK9g8rZ26RuQOpdFj3kN3q1wwQcbMkJauVZVQ==} @@ -1059,8 +1078,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.16.0: - resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} engines: {node: '>=0.4.0'} hasBin: true @@ -1068,8 +1087,8 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} - ajv@6.14.0: - resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} @@ -1151,8 +1170,8 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axe-core@4.11.1: - resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} + axe-core@4.12.1: + resolution: {integrity: sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==} engines: {node: '>=4'} axobject-query@4.1.0: @@ -1170,23 +1189,23 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.10.9: - resolution: {integrity: sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==} + baseline-browser-mapping@2.10.43: + resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} engines: {node: '>=6.0.0'} hasBin: true - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@2.1.2: + resolution: {integrity: sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==} - brace-expansion@5.0.4: - resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} - engines: {node: 18 || 20 || >=22} + brace-expansion@5.0.8: + resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + engines: {node: 20 || >=22} - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + browserslist@4.28.6: + resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1198,8 +1217,8 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} engines: {node: '>= 0.4'} call-bound@1.0.4: @@ -1210,8 +1229,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001780: - resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} @@ -1331,6 +1350,10 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1351,8 +1374,8 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.5.321: - resolution: {integrity: sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==} + electron-to-chromium@1.5.393: + resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1367,8 +1390,12 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - es-abstract@1.24.1: - resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} engines: {node: '>= 0.4'} es-define-property@1.0.1: @@ -1379,15 +1406,15 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-iterator-helpers@1.3.1: - resolution: {integrity: sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==} + es-iterator-helpers@1.4.0: + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} engines: {node: '>= 0.4'} es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-object-atoms@1.1.1: - resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} es-set-tostringtag@2.1.0: @@ -1398,12 +1425,12 @@ packages: resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} engines: {node: '>= 0.4'} - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + es-to-primitive@1.3.4: + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} engines: {node: '>= 0.4'} - esbuild@0.27.4: - resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -1438,11 +1465,11 @@ packages: peerDependencies: eslint: '>=7.0.0' - eslint-import-resolver-node@0.3.9: - resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} - eslint-module-utils@2.12.1: - resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + eslint-module-utils@2.14.0: + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' @@ -1558,8 +1585,8 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} - expect-type@1.3.0: - resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} fast-deep-equal@3.1.3: @@ -1578,7 +1605,7 @@ packages: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} peerDependencies: - picomatch: ^3 || ^4 + picomatch: 4.0.4 peerDependenciesMeta: picomatch: optional: true @@ -1617,8 +1644,8 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} engines: {node: '>= 0.4'} functions-have-names@1.2.3: @@ -1692,8 +1719,8 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} hermes-estree@0.25.1: @@ -1739,8 +1766,8 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} import-fresh@3.3.1: @@ -1782,8 +1809,8 @@ packages: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} engines: {node: '>= 0.4'} is-data-view@1.0.2: @@ -1794,6 +1821,10 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -1903,8 +1934,8 @@ packages: js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} - js-yaml@4.1.1: - resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true jsdom@26.1.0: @@ -1960,6 +1991,76 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2010,8 +2111,8 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - minimatch@10.2.4: - resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} minimatch@3.1.5: @@ -2031,23 +2132,24 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - node-exports-info@1.6.0: - resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + node-exports-info@1.6.2: + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} engines: {node: '>= 0.4'} - node-releases@2.0.36: - resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} - nwsapi@2.2.23: - resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} @@ -2140,16 +2242,16 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + postcss@8.5.18: + resolution: {integrity: sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -2207,22 +2309,22 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} - react-is@19.2.4: - resolution: {integrity: sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==} + react-is@19.2.7: + resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} react-refresh@0.18.0: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} - react-router-dom@7.13.2: - resolution: {integrity: sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==} + react-router-dom@7.18.2: + resolution: {integrity: sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' react-dom: '>=18' - react-router@7.13.2: - resolution: {integrity: sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==} + react-router@7.18.2: + resolution: {integrity: sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -2253,33 +2355,33 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} - reselect@5.1.1: - resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + reselect@5.2.0: + resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==} resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} hasBin: true - resolve@2.0.0-next.6: - resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} engines: {node: '>= 0.4'} hasBin: true - rollup@4.59.0: - resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} safe-push-apply@1.0.0: @@ -2304,8 +2406,8 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - semver@7.7.4: - resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true @@ -2332,8 +2434,8 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} side-channel-map@1.0.1: @@ -2344,8 +2446,8 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} siginfo@2.0.0: @@ -2392,12 +2494,12 @@ packages: string.prototype.repeat@1.0.0: resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} engines: {node: '>= 0.4'} - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} engines: {node: '>= 0.4'} string.prototype.trimstart@1.0.8: @@ -2441,8 +2543,8 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - synckit@0.11.12: - resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} + synckit@0.11.13: + resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} engines: {node: ^14.18.0 || >=16.0.0} test-exclude@7.0.2: @@ -2455,8 +2557,8 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} tinypool@1.1.1: @@ -2511,8 +2613,8 @@ packages: resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} engines: {node: '>= 0.4'} - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} engines: {node: '>= 0.4'} typescript-eslint@8.57.1: @@ -2553,8 +2655,8 @@ packages: engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + vite@7.3.5: + resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -2593,16 +2695,16 @@ packages: yaml: optional: true - vitest@3.2.4: - resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + vitest@3.2.6: + resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@types/debug': ^4.1.12 '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 - '@vitest/browser': 3.2.4 - '@vitest/ui': 3.2.4 + '@vitest/browser': 3.2.6 + '@vitest/ui': 3.2.6 happy-dom: '*' jsdom: '*' peerDependenciesMeta: @@ -2658,8 +2760,8 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} - which-typed-array@1.1.20: - resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} engines: {node: '>= 0.4'} which@2.0.2: @@ -2684,8 +2786,8 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -2706,8 +2808,8 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yaml@1.10.2: - resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + yaml@1.10.3: + resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} yocto-queue@0.1.0: @@ -2720,12 +2822,12 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} snapshots: - '@adobe/css-tools@4.4.4': {} + '@adobe/css-tools@4.5.0': {} '@ampproject/remapping@2.3.0': dependencies: @@ -2740,25 +2842,25 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 - '@babel/code-frame@7.29.0': + '@babel/code-frame@7.29.7': dependencies: - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.29.0': {} + '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.0': + '@babel/core@7.29.6': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-compilation-targets': 7.28.6 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) - '@babel/helpers': 7.29.2 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.6) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -2768,99 +2870,99 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.1': + '@babel/generator@7.29.7': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.28.6': + '@babel/helper-compilation-targets@7.29.7': dependencies: - '@babel/compat-data': 7.29.0 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.6 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-globals@7.28.0': {} + '@babel/helper-globals@7.29.7': {} - '@babel/helper-module-imports@7.28.6': + '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.0 - '@babel/types': 7.29.0 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.29.0 + '@babel/core': 7.29.6 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-string-parser@7.27.1': {} + '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-option@7.27.1': {} + '@babel/helper-validator-option@7.29.7': {} - '@babel/helpers@7.29.2': + '@babel/helpers@7.29.7': dependencies: - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 - '@babel/parser@7.29.2': + '@babel/parser@7.29.7': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 - '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.6)': dependencies: - '@babel/core': 7.29.0 - '@babel/helper-plugin-utils': 7.28.6 + '@babel/core': 7.29.6 + '@babel/helper-plugin-utils': 7.29.7 - '@babel/runtime@7.29.2': {} + '@babel/runtime@7.29.7': {} - '@babel/template@7.28.6': + '@babel/template@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 - '@babel/traverse@7.29.0': + '@babel/traverse@7.29.7': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/generator': 7.29.1 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.29.2 - '@babel/template': 7.28.6 - '@babel/types': 7.29.0 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.29.0': + '@babel/types@7.29.7': dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 '@base-ui-components/utils@0.1.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.29.2 - '@floating-ui/utils': 0.2.11 + '@babel/runtime': 7.29.7 + '@floating-ui/utils': 0.2.12 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - reselect: 5.1.1 + reselect: 5.2.0 use-sync-external-store: 1.6.0(react@19.2.4) optionalDependencies: '@types/react': 19.2.14 @@ -2889,8 +2991,8 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: - '@babel/helper-module-imports': 7.28.6 - '@babel/runtime': 7.29.2 + '@babel/helper-module-imports': 7.29.7 + '@babel/runtime': 7.29.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 '@emotion/serialize': 1.3.3 @@ -2921,7 +3023,7 @@ snapshots: '@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@emotion/babel-plugin': 11.13.5 '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 @@ -2947,7 +3049,7 @@ snapshots: '@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@emotion/babel-plugin': 11.13.5 '@emotion/is-prop-valid': 1.4.0 '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.4) @@ -2970,82 +3072,82 @@ snapshots: '@emotion/weak-memoize@0.4.0': {} - '@esbuild/aix-ppc64@0.27.4': + '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.27.4': + '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.27.4': + '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.27.4': + '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.27.4': + '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.27.4': + '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.27.4': + '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.27.4': + '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.27.4': + '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.27.4': + '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.27.4': + '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.27.4': + '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.27.4': + '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.27.4': + '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.27.4': + '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.27.4': + '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.27.4': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.27.4': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.27.4': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.27.4': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.27.4': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.27.4': + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.27.4': + '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.27.4': + '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.27.4': + '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.27.4': + '@esbuild/win32-x64@0.28.1': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4)': @@ -3073,13 +3175,13 @@ snapshots: '@eslint/eslintrc@3.3.5': dependencies: - ajv: 6.14.0 + ajv: 6.15.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.1.1 + js-yaml: 4.3.0 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3094,17 +3196,22 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@floating-ui/utils@0.2.11': {} + '@floating-ui/utils@0.2.12': {} '@fontsource-variable/inter@5.2.8': {} - '@humanfs/core@0.19.1': {} + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 - '@humanfs/node@0.16.7': + '@humanfs/node@0.16.8': dependencies: - '@humanfs/core': 0.19.1 + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 '@humanwhocodes/retry': 0.4.3 + '@humanfs/types@0.15.0': {} + '@humanwhocodes/module-importer@1.0.1': {} '@humanwhocodes/retry@0.4.3': {} @@ -3118,7 +3225,7 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@istanbuljs/schema@0.1.3': {} + '@istanbuljs/schema@0.1.6': {} '@jridgewell/gen-mapping@0.3.13': dependencies: @@ -3139,15 +3246,15 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@mui/core-downloads-tracker@7.3.9': {} + '@mui/core-downloads-tracker@7.3.11': {} '@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.29.2 - '@mui/core-downloads-tracker': 7.3.9 - '@mui/system': 7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) + '@babel/runtime': 7.29.7 + '@mui/core-downloads-tracker': 7.3.11 + '@mui/system': 7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) '@mui/types': 7.4.12(@types/react@19.2.14) - '@mui/utils': 7.3.9(@types/react@19.2.14)(react@19.2.4) + '@mui/utils': 7.3.11(@types/react@19.2.14)(react@19.2.4) '@popperjs/core': 2.11.8 '@types/react-transition-group': 4.4.12(@types/react@19.2.14) clsx: 2.1.1 @@ -3155,25 +3262,25 @@ snapshots: prop-types: 15.8.1 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - react-is: 19.2.4 + react-is: 19.2.7 react-transition-group: 4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) optionalDependencies: '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.4) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) '@types/react': 19.2.14 - '@mui/private-theming@7.3.9(@types/react@19.2.14)(react@19.2.4)': + '@mui/private-theming@7.3.11(@types/react@19.2.14)(react@19.2.4)': dependencies: - '@babel/runtime': 7.29.2 - '@mui/utils': 7.3.9(@types/react@19.2.14)(react@19.2.4) + '@babel/runtime': 7.29.7 + '@mui/utils': 7.3.11(@types/react@19.2.14)(react@19.2.4) prop-types: 15.8.1 react: 19.2.4 optionalDependencies: '@types/react': 19.2.14 - '@mui/styled-engine@7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)': + '@mui/styled-engine@7.3.10(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@emotion/cache': 11.14.0 '@emotion/serialize': 1.3.3 '@emotion/sheet': 1.4.0 @@ -3184,13 +3291,13 @@ snapshots: '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.4) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) - '@mui/system@7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4)': + '@mui/system@7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4)': dependencies: - '@babel/runtime': 7.29.2 - '@mui/private-theming': 7.3.9(@types/react@19.2.14)(react@19.2.4) - '@mui/styled-engine': 7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) + '@babel/runtime': 7.29.7 + '@mui/private-theming': 7.3.11(@types/react@19.2.14)(react@19.2.4) + '@mui/styled-engine': 7.3.10(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) '@mui/types': 7.4.12(@types/react@19.2.14) - '@mui/utils': 7.3.9(@types/react@19.2.14)(react@19.2.4) + '@mui/utils': 7.3.11(@types/react@19.2.14)(react@19.2.4) clsx: 2.1.1 csstype: 3.2.3 prop-types: 15.8.1 @@ -3202,28 +3309,28 @@ snapshots: '@mui/types@7.4.12(@types/react@19.2.14)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 optionalDependencies: '@types/react': 19.2.14 - '@mui/utils@7.3.9(@types/react@19.2.14)(react@19.2.4)': + '@mui/utils@7.3.11(@types/react@19.2.14)(react@19.2.4)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@mui/types': 7.4.12(@types/react@19.2.14) '@types/prop-types': 15.7.15 clsx: 2.1.1 prop-types: 15.8.1 react: 19.2.4 - react-is: 19.2.4 + react-is: 19.2.7 optionalDependencies: '@types/react': 19.2.14 - '@mui/x-data-grid@8.16.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mui/system@7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@mui/x-data-grid@8.16.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mui/system@7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@mui/material': 7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@mui/system': 7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) - '@mui/utils': 7.3.9(@types/react@19.2.14)(react@19.2.4) + '@mui/system': 7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) + '@mui/utils': 7.3.11(@types/react@19.2.14)(react@19.2.4) '@mui/x-internals': 8.16.0(@types/react@19.2.14)(react@19.2.4) '@mui/x-virtualizer': 0.2.6(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) clsx: 2.1.1 @@ -3237,12 +3344,12 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@mui/x-date-pickers@8.16.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mui/system@7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@mui/x-date-pickers@8.16.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mui/system@7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@mui/material': 7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@mui/system': 7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) - '@mui/utils': 7.3.9(@types/react@19.2.14)(react@19.2.4) + '@mui/system': 7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) + '@mui/utils': 7.3.11(@types/react@19.2.14)(react@19.2.4) '@mui/x-internals': 8.16.0(@types/react@19.2.14)(react@19.2.4) '@types/react-transition-group': 4.4.12(@types/react@19.2.14) clsx: 2.1.1 @@ -3259,31 +3366,31 @@ snapshots: '@mui/x-internals@8.14.0(@types/react@19.2.14)(react@19.2.4)': dependencies: - '@babel/runtime': 7.29.2 - '@mui/utils': 7.3.9(@types/react@19.2.14)(react@19.2.4) + '@babel/runtime': 7.29.7 + '@mui/utils': 7.3.11(@types/react@19.2.14)(react@19.2.4) react: 19.2.4 - reselect: 5.1.1 + reselect: 5.2.0 use-sync-external-store: 1.6.0(react@19.2.4) transitivePeerDependencies: - '@types/react' '@mui/x-internals@8.16.0(@types/react@19.2.14)(react@19.2.4)': dependencies: - '@babel/runtime': 7.29.2 - '@mui/utils': 7.3.9(@types/react@19.2.14)(react@19.2.4) + '@babel/runtime': 7.29.7 + '@mui/utils': 7.3.11(@types/react@19.2.14)(react@19.2.4) react: 19.2.4 - reselect: 5.1.1 + reselect: 5.2.0 use-sync-external-store: 1.6.0(react@19.2.4) transitivePeerDependencies: - '@types/react' - '@mui/x-tree-view@8.14.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mui/system@7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@mui/x-tree-view@8.14.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mui/system@7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@base-ui-components/utils': 0.1.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@mui/material': 7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@mui/system': 7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) - '@mui/utils': 7.3.9(@types/react@19.2.14)(react@19.2.4) + '@mui/system': 7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) + '@mui/utils': 7.3.11(@types/react@19.2.14)(react@19.2.4) '@mui/x-internals': 8.14.0(@types/react@19.2.14)(react@19.2.4) '@types/react-transition-group': 4.4.12(@types/react@19.2.14) clsx: 2.1.1 @@ -3299,8 +3406,8 @@ snapshots: '@mui/x-virtualizer@0.2.6(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.29.2 - '@mui/utils': 7.3.9(@types/react@19.2.14)(react@19.2.4) + '@babel/runtime': 7.29.7 + '@mui/utils': 7.3.11(@types/react@19.2.14)(react@19.2.4) '@mui/x-internals': 8.16.0(@types/react@19.2.14)(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -3310,85 +3417,85 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@pkgr/core@0.2.9': {} + '@pkgr/core@0.3.6': {} '@popperjs/core@2.11.8': {} '@rolldown/pluginutils@1.0.0-rc.3': {} - '@rollup/rollup-android-arm-eabi@4.59.0': + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true - '@rollup/rollup-android-arm64@4.59.0': + '@rollup/rollup-android-arm64@4.62.2': optional: true - '@rollup/rollup-darwin-arm64@4.59.0': + '@rollup/rollup-darwin-arm64@4.62.2': optional: true - '@rollup/rollup-darwin-x64@4.59.0': + '@rollup/rollup-darwin-x64@4.62.2': optional: true - '@rollup/rollup-freebsd-arm64@4.59.0': + '@rollup/rollup-freebsd-arm64@4.62.2': optional: true - '@rollup/rollup-freebsd-x64@4.59.0': + '@rollup/rollup-freebsd-x64@4.62.2': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.59.0': + '@rollup/rollup-linux-arm-musleabihf@4.62.2': optional: true - '@rollup/rollup-linux-arm64-gnu@4.59.0': + '@rollup/rollup-linux-arm64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-arm64-musl@4.59.0': + '@rollup/rollup-linux-arm64-musl@4.62.2': optional: true - '@rollup/rollup-linux-loong64-gnu@4.59.0': + '@rollup/rollup-linux-loong64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-loong64-musl@4.59.0': + '@rollup/rollup-linux-loong64-musl@4.62.2': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.59.0': + '@rollup/rollup-linux-ppc64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-ppc64-musl@4.59.0': + '@rollup/rollup-linux-ppc64-musl@4.62.2': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.59.0': + '@rollup/rollup-linux-riscv64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-riscv64-musl@4.59.0': + '@rollup/rollup-linux-riscv64-musl@4.62.2': optional: true - '@rollup/rollup-linux-s390x-gnu@4.59.0': + '@rollup/rollup-linux-s390x-gnu@4.62.2': optional: true - '@rollup/rollup-linux-x64-gnu@4.59.0': + '@rollup/rollup-linux-x64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-x64-musl@4.59.0': + '@rollup/rollup-linux-x64-musl@4.62.2': optional: true - '@rollup/rollup-openbsd-x64@4.59.0': + '@rollup/rollup-openbsd-x64@4.62.2': optional: true - '@rollup/rollup-openharmony-arm64@4.59.0': + '@rollup/rollup-openharmony-arm64@4.62.2': optional: true - '@rollup/rollup-win32-arm64-msvc@4.59.0': + '@rollup/rollup-win32-arm64-msvc@4.62.2': optional: true - '@rollup/rollup-win32-ia32-msvc@4.59.0': + '@rollup/rollup-win32-ia32-msvc@4.62.2': optional: true - '@rollup/rollup-win32-x64-gnu@4.59.0': + '@rollup/rollup-win32-x64-gnu@4.62.2': optional: true - '@rollup/rollup-win32-x64-msvc@4.59.0': + '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true '@rtsao/scc@1.1.0': {} @@ -3402,8 +3509,8 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.29.2 + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -3413,7 +3520,7 @@ snapshots: '@testing-library/jest-dom@6.9.1': dependencies: - '@adobe/css-tools': 4.4.4 + '@adobe/css-tools': 4.5.0 aria-query: 5.3.2 css.escape: 1.5.1 dom-accessibility-api: 0.6.3 @@ -3422,7 +3529,7 @@ snapshots: '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 '@testing-library/dom': 10.4.1 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -3434,24 +3541,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.0 + '@babel/types': 7.29.7 '@types/chai@5.2.3': dependencies: @@ -3460,7 +3567,7 @@ snapshots: '@types/deep-eql@4.0.2': {} - '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} @@ -3495,7 +3602,7 @@ snapshots: '@typescript-eslint/utils': 8.57.1(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.57.1 eslint: 9.39.4 - ignore: 7.0.5 + ignore: 7.0.6 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -3553,9 +3660,9 @@ snapshots: '@typescript-eslint/types': 8.57.1 '@typescript-eslint/visitor-keys': 8.57.1 debug: 4.4.3 - minimatch: 10.2.4 - semver: 7.7.4 - tinyglobby: 0.2.15 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -3577,19 +3684,19 @@ snapshots: '@typescript-eslint/types': 8.57.1 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@24.12.0))': + '@vitejs/plugin-react@5.2.0(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0))': dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@babel/core': 7.29.6 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.6) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.6) '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@24.12.0) + vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@24.12.0)(jsdom@26.1.0))': + '@vitest/coverage-v8@3.2.6(vitest@3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -3604,49 +3711,53 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.12.0)(jsdom@26.1.0) + vitest: 3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0) transitivePeerDependencies: - supports-color - '@vitest/expect@3.2.4': + '@vitest/expect@3.2.6': dependencies: '@types/chai': 5.2.3 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@24.12.0))': + '@vitest/mocker@3.2.6(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0))': dependencies: - '@vitest/spy': 3.2.4 + '@vitest/spy': 3.2.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@24.12.0) + vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0) + + '@vitest/pretty-format@3.2.6': + dependencies: + tinyrainbow: 2.0.0 - '@vitest/pretty-format@3.2.4': + '@vitest/pretty-format@3.2.7': dependencies: tinyrainbow: 2.0.0 - '@vitest/runner@3.2.4': + '@vitest/runner@3.2.6': dependencies: - '@vitest/utils': 3.2.4 + '@vitest/utils': 3.2.6 pathe: 2.0.3 strip-literal: 3.1.0 - '@vitest/snapshot@3.2.4': + '@vitest/snapshot@3.2.6': dependencies: - '@vitest/pretty-format': 3.2.4 + '@vitest/pretty-format': 3.2.6 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@3.2.4': + '@vitest/spy@3.2.6': dependencies: tinyspy: 4.0.4 - '@vitest/utils@3.2.4': + '@vitest/utils@3.2.6': dependencies: - '@vitest/pretty-format': 3.2.4 + '@vitest/pretty-format': 3.2.6 loupe: 3.2.1 tinyrainbow: 2.0.0 @@ -3655,15 +3766,15 @@ snapshots: lucide-react: 0.545.0(react@19.2.4) react: 19.2.4 - '@wso2/oxygen-ui@0.8.3(@mui/system@7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(@wso2/oxygen-ui-icons-react@0.8.3(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@wso2/oxygen-ui@0.8.3(@mui/system@7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(@wso2/oxygen-ui-icons-react@0.8.3(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@emotion/react': 11.14.0(@types/react@19.2.14)(react@19.2.4) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4) '@fontsource-variable/inter': 5.2.8 '@mui/material': 7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@mui/x-data-grid': 8.16.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mui/system@7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@mui/x-date-pickers': 8.16.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mui/system@7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@mui/x-tree-view': 8.14.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mui/system@7.3.9(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@mui/x-data-grid': 8.16.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mui/system@7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@mui/x-date-pickers': 8.16.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mui/system@7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(date-fns@4.1.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@mui/x-tree-view': 8.14.0(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@mui/material@7.3.4(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mui/system@7.3.11(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@emotion/styled@11.14.1(@emotion/react@11.14.0(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@wso2/oxygen-ui-icons-react': 0.8.3(react@19.2.4) date-fns: 4.1.0 prismjs: 1.30.0 @@ -3681,15 +3792,15 @@ snapshots: - moment-jalaali - supports-color - acorn-jsx@5.3.2(acorn@8.16.0): + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: - acorn: 8.16.0 + acorn: 8.17.0 - acorn@8.16.0: {} + acorn@8.17.0: {} agent-base@7.1.4: {} - ajv@6.14.0: + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 fast-json-stable-stringify: 2.1.0 @@ -3723,62 +3834,62 @@ snapshots: array-includes@3.1.9: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 is-string: 1.1.1 math-intrinsics: 1.1.0 array.prototype.findlast@1.2.5: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 es-shim-unscopables: 1.1.0 array.prototype.findlastindex@1.2.6: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 es-shim-unscopables: 1.1.0 array.prototype.flat@1.3.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-shim-unscopables: 1.1.0 array.prototype.flatmap@1.3.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-shim-unscopables: 1.1.0 array.prototype.tosorted@1.1.4: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 es-shim-unscopables: 1.1.0 arraybuffer.prototype.slice@1.0.4: dependencies: array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 @@ -3799,42 +3910,42 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axe-core@4.11.1: {} + axe-core@4.12.1: {} axobject-query@4.1.0: {} babel-plugin-macros@3.1.0: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 cosmiconfig: 7.1.0 - resolve: 1.22.11 + resolve: 1.22.12 balanced-match@1.0.2: {} balanced-match@4.0.4: {} - baseline-browser-mapping@2.10.9: {} + baseline-browser-mapping@2.10.43: {} - brace-expansion@1.1.12: + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.2: + brace-expansion@2.1.2: dependencies: balanced-match: 1.0.2 - brace-expansion@5.0.4: + brace-expansion@5.0.8: dependencies: balanced-match: 4.0.4 - browserslist@4.28.1: + browserslist@4.28.6: dependencies: - baseline-browser-mapping: 2.10.9 - caniuse-lite: 1.0.30001780 - electron-to-chromium: 1.5.321 - node-releases: 2.0.36 - update-browserslist-db: 1.2.3(browserslist@4.28.1) + baseline-browser-mapping: 2.10.43 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.393 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.6) cac@6.7.14: {} @@ -3843,7 +3954,7 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.8: + call-bind@1.0.9: dependencies: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 @@ -3857,7 +3968,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001780: {} + caniuse-lite@1.0.30001806: {} chai@5.3.3: dependencies: @@ -3898,7 +4009,7 @@ snapshots: import-fresh: 3.3.1 parse-json: 5.2.0 path-type: 4.0.0 - yaml: 1.10.2 + yaml: 1.10.3 cross-spawn@7.0.6: dependencies: @@ -3970,6 +4081,9 @@ snapshots: dequal@2.0.3: {} + detect-libc@2.1.2: + optional: true + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -3980,7 +4094,7 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 csstype: 3.2.3 dunder-proto@1.0.1: @@ -3991,7 +4105,7 @@ snapshots: eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.321: {} + electron-to-chromium@1.5.393: {} emoji-regex@8.0.0: {} @@ -4003,22 +4117,29 @@ snapshots: dependencies: is-arrayish: 0.2.1 - es-abstract@1.24.1: + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: dependencies: array-buffer-byte-length: 1.0.2 arraybuffer.prototype.slice: 1.0.4 available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 data-view-buffer: 1.0.2 data-view-byte-length: 1.0.2 data-view-byte-offset: 1.0.1 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 + es-to-primitive: 1.3.4 + function.prototype.name: 1.2.0 get-intrinsic: 1.3.0 get-proto: 1.0.1 get-symbol-description: 1.1.0 @@ -4027,7 +4148,7 @@ snapshots: has-property-descriptors: 1.0.2 has-proto: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 internal-slot: 1.1.0 is-array-buffer: 3.0.5 is-callable: 1.2.7 @@ -4045,31 +4166,31 @@ snapshots: object.assign: 4.1.7 own-keys: 1.0.1 regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 + safe-array-concat: 1.1.4 safe-push-apply: 1.0.0 safe-regex-test: 1.1.0 set-proto: 1.0.0 stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.10 - string.prototype.trimend: 1.0.9 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 string.prototype.trimstart: 1.0.8 typed-array-buffer: 1.0.3 typed-array-byte-length: 1.0.3 typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 + typed-array-length: 1.0.8 unbox-primitive: 1.1.0 - which-typed-array: 1.1.20 + which-typed-array: 1.1.22 es-define-property@1.0.1: {} es-errors@1.3.0: {} - es-iterator-helpers@1.3.1: + es-iterator-helpers@1.4.0: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 es-set-tostringtag: 2.1.0 function-bind: 1.1.2 @@ -4082,11 +4203,10 @@ snapshots: internal-slot: 1.1.0 iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 - safe-array-concat: 1.1.3 es-module-lexer@1.7.0: {} - es-object-atoms@1.1.1: + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -4095,46 +4215,49 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 es-shim-unscopables@1.1.0: dependencies: - hasown: 2.0.2 + hasown: 2.0.4 - es-to-primitive@1.3.0: + es-to-primitive@1.3.4: dependencies: + es-abstract-get: 1.0.0 + es-define-property: 1.0.1 + es-errors: 1.3.0 is-callable: 1.2.7 is-date-object: 1.1.0 is-symbol: 1.1.1 - esbuild@0.27.4: + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.4 - '@esbuild/android-arm': 0.27.4 - '@esbuild/android-arm64': 0.27.4 - '@esbuild/android-x64': 0.27.4 - '@esbuild/darwin-arm64': 0.27.4 - '@esbuild/darwin-x64': 0.27.4 - '@esbuild/freebsd-arm64': 0.27.4 - '@esbuild/freebsd-x64': 0.27.4 - '@esbuild/linux-arm': 0.27.4 - '@esbuild/linux-arm64': 0.27.4 - '@esbuild/linux-ia32': 0.27.4 - '@esbuild/linux-loong64': 0.27.4 - '@esbuild/linux-mips64el': 0.27.4 - '@esbuild/linux-ppc64': 0.27.4 - '@esbuild/linux-riscv64': 0.27.4 - '@esbuild/linux-s390x': 0.27.4 - '@esbuild/linux-x64': 0.27.4 - '@esbuild/netbsd-arm64': 0.27.4 - '@esbuild/netbsd-x64': 0.27.4 - '@esbuild/openbsd-arm64': 0.27.4 - '@esbuild/openbsd-x64': 0.27.4 - '@esbuild/openharmony-arm64': 0.27.4 - '@esbuild/sunos-x64': 0.27.4 - '@esbuild/win32-arm64': 0.27.4 - '@esbuild/win32-ia32': 0.27.4 - '@esbuild/win32-x64': 0.27.4 + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escalade@3.2.0: {} @@ -4164,21 +4287,21 @@ snapshots: dependencies: eslint: 9.39.4 - eslint-import-resolver-node@0.3.9: + eslint-import-resolver-node@0.3.10: dependencies: debug: 3.2.7 - is-core-module: 2.16.1 - resolve: 1.22.11 + is-core-module: 2.16.2 + resolve: 2.0.0-next.7 transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4): + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.57.1(eslint@9.39.4)(typescript@5.9.3) eslint: 9.39.4 - eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-node: 0.3.10 transitivePeerDependencies: - supports-color @@ -4192,17 +4315,17 @@ snapshots: debug: 3.2.7 doctrine: 2.1.0 eslint: 9.39.4 - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.57.1(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint@9.39.4) - hasown: 2.0.2 - is-core-module: 2.16.1 + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) + hasown: 2.0.4 + is-core-module: 2.16.2 is-glob: 4.0.3 minimatch: 3.1.5 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.1 semver: 6.3.1 - string.prototype.trimend: 1.0.9 + string.prototype.trimend: 1.0.10 tsconfig-paths: 3.15.0 optionalDependencies: '@typescript-eslint/parser': 8.57.1(eslint@9.39.4)(typescript@5.9.3) @@ -4217,12 +4340,12 @@ snapshots: array-includes: 3.1.9 array.prototype.flatmap: 1.3.3 ast-types-flow: 0.0.8 - axe-core: 4.11.1 + axe-core: 4.12.1 axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 eslint: 9.39.4 - hasown: 2.0.2 + hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 minimatch: 3.1.5 @@ -4235,18 +4358,18 @@ snapshots: eslint: 9.39.4 prettier: 3.8.1 prettier-linter-helpers: 1.0.1 - synckit: 0.11.12 + synckit: 0.11.13 optionalDependencies: eslint-config-prettier: 10.1.8(eslint@9.39.4) eslint-plugin-react-hooks@7.0.1(eslint@9.39.4): dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.2 + '@babel/core': 7.29.6 + '@babel/parser': 7.29.7 eslint: 9.39.4 hermes-parser: 0.25.1 - zod: 4.3.6 - zod-validation-error: 4.0.2(zod@4.3.6) + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) transitivePeerDependencies: - supports-color @@ -4261,17 +4384,17 @@ snapshots: array.prototype.flatmap: 1.3.3 array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 - es-iterator-helpers: 1.3.1 + es-iterator-helpers: 1.4.0 eslint: 9.39.4 estraverse: 5.3.0 - hasown: 2.0.2 + hasown: 2.0.4 jsx-ast-utils: 3.3.5 minimatch: 3.1.5 object.entries: 1.1.9 object.fromentries: 2.0.8 object.values: 1.2.1 prop-types: 15.8.1 - resolve: 2.0.0-next.6 + resolve: 2.0.0-next.7 semver: 6.3.1 string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 @@ -4297,11 +4420,11 @@ snapshots: '@eslint/eslintrc': 3.3.5 '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 + '@humanfs/node': 0.16.8 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.14.0 + '@types/estree': 1.0.9 + ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 debug: 4.4.3 @@ -4328,8 +4451,8 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.16.0 - acorn-jsx: 5.3.2(acorn@8.16.0) + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) eslint-visitor-keys: 4.2.1 esquery@1.7.0: @@ -4344,11 +4467,11 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esutils@2.0.3: {} - expect-type@1.3.0: {} + expect-type@1.4.0: {} fast-deep-equal@3.1.3: {} @@ -4358,9 +4481,9 @@ snapshots: fast-levenshtein@2.0.6: {} - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 file-entry-cache@8.0.0: dependencies: @@ -4394,14 +4517,17 @@ snapshots: function-bind@1.1.2: {} - function.prototype.name@1.1.8: + function.prototype.name@1.2.0: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 - define-properties: 1.2.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 functions-have-names: 1.2.3 - hasown: 2.0.2 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 is-callable: 1.2.7 + is-document.all: 1.0.0 functions-have-names@1.2.3: {} @@ -4414,18 +4540,18 @@ snapshots: call-bind-apply-helpers: 1.0.2 es-define-property: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 function-bind: 1.1.2 get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-symbol-description@1.1.0: dependencies: @@ -4475,7 +4601,7 @@ snapshots: dependencies: has-symbols: 1.1.0 - hasown@2.0.2: + hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -4515,7 +4641,7 @@ snapshots: i18next@25.10.5(typescript@5.9.3): dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 optionalDependencies: typescript: 5.9.3 @@ -4525,7 +4651,7 @@ snapshots: ignore@5.3.2: {} - ignore@7.0.5: {} + ignore@7.0.6: {} import-fresh@3.3.1: dependencies: @@ -4539,12 +4665,12 @@ snapshots: internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.2 - side-channel: 1.1.0 + hasown: 2.0.4 + side-channel: 1.1.1 is-array-buffer@3.0.5: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 get-intrinsic: 1.3.0 @@ -4569,9 +4695,9 @@ snapshots: is-callable@1.2.7: {} - is-core-module@2.16.1: + is-core-module@2.16.2: dependencies: - hasown: 2.0.2 + hasown: 2.0.4 is-data-view@1.0.2: dependencies: @@ -4584,6 +4710,10 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + is-extglob@2.1.1: {} is-finalizationregistry@1.1.1: @@ -4620,7 +4750,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 is-set@2.0.3: {} @@ -4641,7 +4771,7 @@ snapshots: is-typed-array@1.1.15: dependencies: - which-typed-array: 1.1.20 + which-typed-array: 1.1.22 is-weakmap@2.0.2: {} @@ -4682,7 +4812,7 @@ snapshots: iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 get-proto: 1.0.1 has-symbols: 1.1.0 @@ -4700,7 +4830,7 @@ snapshots: js-tokens@9.0.1: {} - js-yaml@4.1.1: + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -4713,7 +4843,7 @@ snapshots: http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 is-potential-custom-element-name: 1.0.1 - nwsapi: 2.2.23 + nwsapi: 2.2.24 parse5: 7.3.0 rrweb-cssom: 0.8.0 saxes: 6.0.0 @@ -4724,7 +4854,7 @@ snapshots: whatwg-encoding: 3.1.1 whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 - ws: 8.19.0 + ws: 8.21.0 xml-name-validator: 5.0.0 transitivePeerDependencies: - bufferutil @@ -4769,6 +4899,56 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + optional: true + lines-and-columns@1.2.4: {} locate-path@6.0.0: @@ -4801,29 +4981,29 @@ snapshots: magicast@0.3.5: dependencies: - '@babel/parser': 7.29.2 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 source-map-js: 1.2.1 make-dir@4.0.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 math-intrinsics@1.1.0: {} min-indent@1.0.1: {} - minimatch@10.2.4: + minimatch@10.2.5: dependencies: - brace-expansion: 5.0.4 + brace-expansion: 5.0.8 minimatch@3.1.5: dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.16 minimatch@9.0.9: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.1.2 minimist@1.2.8: {} @@ -4831,20 +5011,20 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.11: {} + nanoid@3.3.16: {} natural-compare@1.4.0: {} - node-exports-info@1.6.0: + node-exports-info@1.6.2: dependencies: array.prototype.flatmap: 1.3.3 es-errors: 1.3.0 object.entries: 1.1.9 semver: 6.3.1 - node-releases@2.0.36: {} + node-releases@2.0.51: {} - nwsapi@2.2.23: {} + nwsapi@2.2.24: {} object-assign@4.1.1: {} @@ -4854,39 +5034,39 @@ snapshots: object.assign@4.1.7: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 has-symbols: 1.1.0 object-keys: 1.1.1 object.entries@1.1.9: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 object.fromentries@2.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 object.groupby@1.0.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 object.values@1.2.1: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 optionator@0.9.4: dependencies: @@ -4919,7 +5099,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -4947,13 +5127,13 @@ snapshots: picocolors@1.1.1: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} possible-typed-array-names@1.1.0: {} - postcss@8.5.8: + postcss@8.5.18: dependencies: - nanoid: 3.3.11 + nanoid: 3.3.16 picocolors: 1.1.1 source-map-js: 1.2.1 @@ -4988,7 +5168,7 @@ snapshots: react-i18next@16.6.2(i18next@25.10.5(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 html-parse-stringify: 3.0.1 i18next: 25.10.5(typescript@5.9.3) react: 19.2.4 @@ -5001,17 +5181,17 @@ snapshots: react-is@17.0.2: {} - react-is@19.2.4: {} + react-is@19.2.7: {} react-refresh@0.18.0: {} - react-router-dom@7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-router-dom@7.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - react-router: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react-router: 7.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - react-router@7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + react-router@7.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: cookie: 1.1.1 react: 19.2.4 @@ -5021,7 +5201,7 @@ snapshots: react-transition-group@4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - '@babel/runtime': 7.29.2 + '@babel/runtime': 7.29.7 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -5037,79 +5217,80 @@ snapshots: reflect.getprototypeof@1.0.10: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 get-proto: 1.0.1 which-builtin-type: 1.2.1 regexp.prototype.flags@1.5.4: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 es-errors: 1.3.0 get-proto: 1.0.1 gopd: 1.2.0 set-function-name: 2.0.2 - reselect@5.1.1: {} + reselect@5.2.0: {} resolve-from@4.0.0: {} - resolve@1.22.11: + resolve@1.22.12: dependencies: - is-core-module: 2.16.1 + es-errors: 1.3.0 + is-core-module: 2.16.2 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - resolve@2.0.0-next.6: + resolve@2.0.0-next.7: dependencies: es-errors: 1.3.0 - is-core-module: 2.16.1 - node-exports-info: 1.6.0 + is-core-module: 2.16.2 + node-exports-info: 1.6.2 object-keys: 1.1.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - rollup@4.59.0: + rollup@4.62.2: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.59.0 - '@rollup/rollup-android-arm64': 4.59.0 - '@rollup/rollup-darwin-arm64': 4.59.0 - '@rollup/rollup-darwin-x64': 4.59.0 - '@rollup/rollup-freebsd-arm64': 4.59.0 - '@rollup/rollup-freebsd-x64': 4.59.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 - '@rollup/rollup-linux-arm-musleabihf': 4.59.0 - '@rollup/rollup-linux-arm64-gnu': 4.59.0 - '@rollup/rollup-linux-arm64-musl': 4.59.0 - '@rollup/rollup-linux-loong64-gnu': 4.59.0 - '@rollup/rollup-linux-loong64-musl': 4.59.0 - '@rollup/rollup-linux-ppc64-gnu': 4.59.0 - '@rollup/rollup-linux-ppc64-musl': 4.59.0 - '@rollup/rollup-linux-riscv64-gnu': 4.59.0 - '@rollup/rollup-linux-riscv64-musl': 4.59.0 - '@rollup/rollup-linux-s390x-gnu': 4.59.0 - '@rollup/rollup-linux-x64-gnu': 4.59.0 - '@rollup/rollup-linux-x64-musl': 4.59.0 - '@rollup/rollup-openbsd-x64': 4.59.0 - '@rollup/rollup-openharmony-arm64': 4.59.0 - '@rollup/rollup-win32-arm64-msvc': 4.59.0 - '@rollup/rollup-win32-ia32-msvc': 4.59.0 - '@rollup/rollup-win32-x64-gnu': 4.59.0 - '@rollup/rollup-win32-x64-msvc': 4.59.0 + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 rrweb-cssom@0.8.0: {} - safe-array-concat@1.1.3: + safe-array-concat@1.1.4: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 get-intrinsic: 1.3.0 has-symbols: 1.1.0 @@ -5136,7 +5317,7 @@ snapshots: semver@6.3.1: {} - semver@7.7.4: {} + semver@7.8.5: {} set-cookie-parser@2.7.2: {} @@ -5160,7 +5341,7 @@ snapshots: dependencies: dunder-proto: 1.0.1 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 shebang-command@2.0.0: dependencies: @@ -5168,7 +5349,7 @@ snapshots: shebang-regex@3.0.0: {} - side-channel-list@1.0.0: + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -5188,11 +5369,11 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.0: + side-channel@1.1.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 - side-channel-list: 1.0.0 + side-channel-list: 1.0.1 side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 @@ -5227,53 +5408,54 @@ snapshots: string.prototype.includes@2.0.1: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 string.prototype.matchall@4.0.12: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 es-errors: 1.3.0 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 get-intrinsic: 1.3.0 gopd: 1.2.0 has-symbols: 1.1.0 internal-slot: 1.1.0 regexp.prototype.flags: 1.5.4 set-function-name: 2.0.2 - side-channel: 1.1.0 + side-channel: 1.1.1 string.prototype.repeat@1.0.0: dependencies: define-properties: 1.2.1 - es-abstract: 1.24.1 + es-abstract: 1.24.2 - string.prototype.trim@1.2.10: + string.prototype.trim@1.2.11: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-data-property: 1.1.4 define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 - string.prototype.trimend@1.0.9: + string.prototype.trimend@1.0.10: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 string.prototype.trimstart@1.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 define-properties: 1.2.1 - es-object-atoms: 1.1.1 + es-object-atoms: 1.1.2 strip-ansi@6.0.1: dependencies: @@ -5305,24 +5487,24 @@ snapshots: symbol-tree@3.2.4: {} - synckit@0.11.12: + synckit@0.11.13: dependencies: - '@pkgr/core': 0.2.9 + '@pkgr/core': 0.3.6 test-exclude@7.0.2: dependencies: - '@istanbuljs/schema': 0.1.3 + '@istanbuljs/schema': 0.1.6 glob: 10.5.0 - minimatch: 10.2.4 + minimatch: 10.2.5 tinybench@2.9.0: {} tinyexec@0.3.2: {} - tinyglobby@0.2.15: + tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinypool@1.1.1: {} @@ -5367,7 +5549,7 @@ snapshots: typed-array-byte-length@1.0.3: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 has-proto: 1.2.0 @@ -5376,16 +5558,16 @@ snapshots: typed-array-byte-offset@1.0.4: dependencies: available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 has-proto: 1.2.0 is-typed-array: 1.1.15 reflect.getprototypeof: 1.0.10 - typed-array-length@1.0.7: + typed-array-length@1.0.8: dependencies: - call-bind: 1.0.8 + call-bind: 1.0.9 for-each: 0.3.5 gopd: 1.2.0 is-typed-array: 1.1.15 @@ -5414,9 +5596,9 @@ snapshots: undici-types@7.16.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.1): + update-browserslist-db@1.2.3(browserslist@4.28.6): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.6 escalade: 3.2.0 picocolors: 1.1.1 @@ -5428,13 +5610,13 @@ snapshots: dependencies: react: 19.2.4 - vite-node@3.2.4(@types/node@24.12.0): + vite-node@3.2.4(@types/node@24.12.0)(lightningcss@1.32.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@24.12.0) + vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0) transitivePeerDependencies: - '@types/node' - jiti @@ -5449,42 +5631,43 @@ snapshots: - tsx - yaml - vite@7.3.1(@types/node@24.12.0): + vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0): dependencies: - esbuild: 0.27.4 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.8 - rollup: 4.59.0 - tinyglobby: 0.2.15 + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.18 + rollup: 4.62.2 + tinyglobby: 0.2.17 optionalDependencies: '@types/node': 24.12.0 fsevents: 2.3.3 + lightningcss: 1.32.0 - vitest@3.2.4(@types/node@24.12.0)(jsdom@26.1.0): + vitest@3.2.6(@types/node@24.12.0)(jsdom@26.1.0)(lightningcss@1.32.0): dependencies: '@types/chai': 5.2.3 - '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@24.12.0)) - '@vitest/pretty-format': 3.2.4 - '@vitest/runner': 3.2.4 - '@vitest/snapshot': 3.2.4 - '@vitest/spy': 3.2.4 - '@vitest/utils': 3.2.4 + '@vitest/expect': 3.2.6 + '@vitest/mocker': 3.2.6(vite@7.3.5(@types/node@24.12.0)(lightningcss@1.32.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.6 + '@vitest/snapshot': 3.2.6 + '@vitest/spy': 3.2.6 + '@vitest/utils': 3.2.6 chai: 5.3.3 debug: 4.4.3 - expect-type: 1.3.0 + expect-type: 1.4.0 magic-string: 0.30.21 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 0.3.2 - tinyglobby: 0.2.15 + tinyglobby: 0.2.17 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@24.12.0) - vite-node: 3.2.4(@types/node@24.12.0) + vite: 7.3.5(@types/node@24.12.0)(lightningcss@1.32.0) + vite-node: 3.2.4(@types/node@24.12.0)(lightningcss@1.32.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.12.0 @@ -5533,7 +5716,7 @@ snapshots: which-builtin-type@1.2.1: dependencies: call-bound: 1.0.4 - function.prototype.name: 1.1.8 + function.prototype.name: 1.2.0 has-tostringtag: 1.0.2 is-async-function: 2.1.1 is-date-object: 1.1.0 @@ -5544,7 +5727,7 @@ snapshots: isarray: 2.0.5 which-boxed-primitive: 1.1.1 which-collection: 1.0.2 - which-typed-array: 1.1.20 + which-typed-array: 1.1.22 which-collection@1.0.2: dependencies: @@ -5553,10 +5736,10 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 - which-typed-array@1.1.20: + which-typed-array@1.1.22: dependencies: available-typed-arrays: 1.0.7 - call-bind: 1.0.8 + call-bind: 1.0.9 call-bound: 1.0.4 for-each: 0.3.5 get-proto: 1.0.1 @@ -5586,7 +5769,7 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.2.0 - ws@8.19.0: {} + ws@8.21.0: {} xml-name-validator@5.0.0: {} @@ -5594,12 +5777,12 @@ snapshots: yallist@3.1.1: {} - yaml@1.10.2: {} + yaml@1.10.3: {} yocto-queue@0.1.0: {} - zod-validation-error@4.0.2(zod@4.3.6): + zod-validation-error@4.0.2(zod@4.4.3): dependencies: - zod: 4.3.6 + zod: 4.4.3 - zod@4.3.6: {} + zod@4.4.3: {} diff --git a/portal/frontend/scripts/audit-production-dependencies.mjs b/portal/frontend/scripts/audit-production-dependencies.mjs new file mode 100644 index 00000000..8a6fa092 --- /dev/null +++ b/portal/frontend/scripts/audit-production-dependencies.mjs @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + */ + +import { spawnSync } from 'node:child_process' + +const allowedAdvisories = new Map([ + [ + 'GHSA-qwww-vcr4-c8h2', + { + description: 'React Router unstable RSC APIs are not used by this Vite SPA', + pathPattern: /^\. > react-router-dom@[^ ]+ > react-router@[^ ]+$/, + }, + ], +]) + +const pnpmCli = process.env.npm_execpath + +if (!pnpmCli) { + throw new Error('Unable to locate the active pnpm CLI from npm_execpath') +} + +const audit = spawnSync(process.execPath, [pnpmCli, 'audit', '--prod', '--json'], { + cwd: process.cwd(), + encoding: 'utf8', + shell: false, +}) + +if (audit.error) { + throw new Error(`Unable to run pnpm audit: ${audit.error.message}`) +} + +let report + +try { + report = JSON.parse(audit.stdout) +} catch { + const details = [audit.stdout, audit.stderr].filter(Boolean).join('\n').trim() + throw new Error(`Unable to parse pnpm audit output${details ? `:\n${details}` : ''}`) +} + +if (audit.status !== 0 && audit.status !== 1) { + throw new Error(`pnpm audit failed with exit code ${String(audit.status)}:\n${audit.stderr}`) +} + +const advisories = Object.values(report.advisories ?? {}) +const blockingAdvisories = [] +const acceptedAdvisories = [] + +for (const advisory of advisories) { + if (advisory.severity === 'high' || advisory.severity === 'critical') { + const exception = allowedAdvisories.get(advisory.github_advisory_id) + const paths = advisory.findings?.flatMap((finding) => finding.paths ?? []) ?? [] + const pathsMatch = paths.length > 0 && paths.every((path) => exception?.pathPattern.test(path)) + + if (exception && pathsMatch) { + acceptedAdvisories.push({ advisory, description: exception.description }) + } else { + blockingAdvisories.push(advisory) + } + } +} + +for (const { advisory, description } of acceptedAdvisories) { + console.warn( + `Accepted documented advisory ${advisory.github_advisory_id}: ${advisory.title}\n` + + `Reason: ${description}\n`, + ) +} + +if (blockingAdvisories.length > 0) { + const summary = blockingAdvisories + .map( + (advisory) => + `- ${advisory.github_advisory_id ?? advisory.id}: ${advisory.title} (${advisory.severity})`, + ) + .join('\n') + + throw new Error(`Production dependency audit found blocking advisories:\n${summary}`) +} + +console.log('Production dependency audit passed.') diff --git a/portal/frontend/scripts/verify-production-security.mjs b/portal/frontend/scripts/verify-production-security.mjs new file mode 100644 index 00000000..59d08c15 --- /dev/null +++ b/portal/frontend/scripts/verify-production-security.mjs @@ -0,0 +1,84 @@ +/* + * 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. + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' + +const root = resolve(import.meta.dirname, '..') +const html = readFileSync(join(root, 'dist', 'index.html'), 'utf8') +const headers = readFileSync(join(root, 'dist', '_headers'), 'utf8') + +const failures = [] +const assertAbsent = (value, pattern, message) => { + if (pattern.test(value)) failures.push(message) +} + +assertAbsent(html, /]*\bsrc=)[^>]*>/i, 'production HTML contains an inline script') +assertAbsent(html, /\son[a-z]+\s*=/i, 'production HTML contains an inline event handler') +assertAbsent(html, /\sstyle\s*=/i, 'production HTML contains an inline style attribute') +assertAbsent(html, / { + for (const name of readdirSync(directory)) { + const path = join(directory, name) + if (statSync(path).isDirectory()) { + if (name !== '__tests__') collect(path) + } else if ( + /\.(ts|tsx)$/.test(name) && + !name.endsWith('.test.ts') && + !name.endsWith('.test.tsx') + ) { + productionSources.push(path) + } + } +} +collect(join(root, 'src')) + +const forbiddenSinks = [ + [/dangerouslySetInnerHTML/, 'dangerouslySetInnerHTML'], + [/\.innerHTML\b/, 'innerHTML'], + [/\.outerHTML\b/, 'outerHTML'], + [/document\.write\s*\(/, 'document.write'], + [/\beval\s*\(/, 'eval'], + [/\bnew\s+Function\s*\(/, 'Function constructor'], + [/\bconsole\.(?:debug|error|info|log|warn)\s*\(/, 'console logging'], + [/\blocalStorage\b/, 'localStorage'], + [/\bsessionStorage\b/, 'sessionStorage'], +] + +for (const path of productionSources) { + const source = readFileSync(path, 'utf8') + for (const [pattern, sink] of forbiddenSinks) { + if (pattern.test(source)) { + failures.push(`${relative(root, path)} contains forbidden sink ${sink}`) + } + } +} + +if (failures.length > 0) { + throw new Error(`Production security verification failed:\n- ${failures.join('\n- ')}`) +} + +console.log('Production CSP, HTML, and source-sink verification passed.') diff --git a/portal/frontend/src/App.tsx b/portal/frontend/src/App.tsx index dbbb2956..4bfad7b9 100644 --- a/portal/frontend/src/App.tsx +++ b/portal/frontend/src/App.tsx @@ -16,22 +16,42 @@ * under the License. */ +import { useEffect } from 'react' import { Navigate, Route, Routes } from 'react-router-dom' import MainLayout from './components/layout/main-layout/MainLayout' import ConsentDetailsPage from './features/consent-registry/ConsentDetailsPage' import ConsentRegistryPage from './features/consent-registry/ConsentRegistryPage' import DashboardPage from './features/dashboard/DashboardPage' +import { isAuthenticated, login } from './utils/authClient' + +function AuthenticationGate({ + children, +}: { + children: React.JSX.Element +}): React.JSX.Element | null { + const authenticated = isAuthenticated() + + useEffect(() => { + if (!authenticated) { + login() + } + }, [authenticated]) + + return authenticated ? children : null +} function App(): React.JSX.Element { return ( - - }> - } /> - } /> - } /> - } /> - - + + + }> + } /> + } /> + } /> + } /> + + + ) } diff --git a/portal/frontend/src/__tests__/ApiClientAuth.test.ts b/portal/frontend/src/__tests__/ApiClientAuth.test.ts new file mode 100644 index 00000000..f377ad61 --- /dev/null +++ b/portal/frontend/src/__tests__/ApiClientAuth.test.ts @@ -0,0 +1,176 @@ +/* + * 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. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { APIError, apiRequest, apiRequestNoContent } from '../utils/apiClient' + +const authMocks = vi.hoisted(() => ({ + getAccessTokenPart1: vi.fn<() => string | undefined>(), + isAuthEnabled: vi.fn<() => boolean>(), + login: vi.fn<() => void>(), + refreshSession: vi.fn<() => Promise>(), +})) + +vi.mock('../utils/authClient', () => authMocks) + +function jsonResponse(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +beforeEach(() => { + vi.stubEnv('VITE_API_BASE_URL', 'http://api.example/') + authMocks.getAccessTokenPart1.mockReturnValue('access-part') + authMocks.isAuthEnabled.mockReturnValue(true) + authMocks.refreshSession.mockResolvedValue() +}) + +afterEach(() => { + vi.clearAllMocks() + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) + +describe('authenticated API client', () => { + it('attaches the readable access half and credentials to requests', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: true })) + vi.stubGlobal('fetch', fetchMock) + + await expect( + apiRequest('/consents', { query: { limit: 10, active: true, ignored: undefined } }), + ).resolves.toEqual({ ok: true }) + + const [requestURL, requestInit] = fetchMock.mock.calls[0] ?? [] + expect(requestURL).toBe('http://api.example/consents?limit=10&active=true') + expect(requestInit?.credentials).toBe('include') + const headers = new Headers(requestInit?.headers) + expect(headers.get('Accept')).toBe('application/json') + expect(headers.get('Authorization')).toBe('Bearer access-part') + }) + + it('does not overwrite a caller-supplied Authorization header', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: true })) + vi.stubGlobal('fetch', fetchMock) + + await apiRequest('/consents', { headers: { Authorization: 'Custom credential' } }) + + const [, requestInit] = fetchMock.mock.calls[0] ?? [] + expect(new Headers(requestInit?.headers).get('Authorization')).toBe('Custom credential') + }) + + it('does not refresh or start login for a 403 response', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + jsonResponse({ code: 'FORBIDDEN', message: 'insufficient permissions' }, 403), + ), + ) + + await expect(apiRequest('/admin')).rejects.toMatchObject({ + name: 'APIError', + status: 403, + code: 'FORBIDDEN', + message: 'insufficient permissions', + }) + expect(authMocks.refreshSession).not.toHaveBeenCalled() + expect(authMocks.login).not.toHaveBeenCalled() + }) + + it('retries only once and starts login when the retry is also unauthorized', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ code: 'UNAUTHORIZED' }, 401)) + vi.stubGlobal('fetch', fetchMock) + + await expect(apiRequest('/consents')).rejects.toMatchObject({ status: 401 }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(authMocks.refreshSession).toHaveBeenCalledOnce() + expect(authMocks.login).toHaveBeenCalledOnce() + }) + + it('starts login when refresh fails without creating a retry loop', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ code: 'UNAUTHORIZED' }, 401)) + authMocks.refreshSession.mockRejectedValueOnce(new Error('refresh failed')) + vi.stubGlobal('fetch', fetchMock) + + await expect(apiRequest('/consents')).rejects.toMatchObject({ status: 401 }) + + expect(fetchMock).toHaveBeenCalledOnce() + expect(authMocks.refreshSession).toHaveBeenCalledOnce() + expect(authMocks.login).toHaveBeenCalledOnce() + }) + + it('does not refresh or start login for a 401 when authentication is disabled', async () => { + authMocks.isAuthEnabled.mockReturnValue(false) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({}, 401))) + + await expect(apiRequest('/consents')).rejects.toMatchObject({ status: 401 }) + + expect(authMocks.refreshSession).not.toHaveBeenCalled() + expect(authMocks.login).not.toHaveBeenCalled() + }) + + it('supports no-content requests and one refresh retry', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ code: 'UNAUTHORIZED' }, 401)) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetchMock) + + await expect(apiRequestNoContent('/consents/1')).resolves.toBeUndefined() + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(authMocks.refreshSession).toHaveBeenCalledOnce() + expect(authMocks.login).not.toHaveBeenCalled() + }) + + it('starts login when a no-content retry remains unauthorized', async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({}, 401)) + vi.stubGlobal('fetch', fetchMock) + + await expect(apiRequestNoContent('/consents/1')).rejects.toMatchObject({ status: 401 }) + + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(authMocks.refreshSession).toHaveBeenCalledOnce() + expect(authMocks.login).toHaveBeenCalledOnce() + }) + + it('uses fallback API errors for non-JSON responses', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('failure', { status: 502 }))) + + const failure = apiRequest('/consents') + await expect(failure).rejects.toBeInstanceOf(APIError) + await expect(failure).rejects.toMatchObject({ + status: 502, + code: 'API_REQUEST_FAILED', + message: 'request failed with status 502', + }) + }) + + it('rejects a missing API base URL before calling fetch', async () => { + vi.stubEnv('VITE_API_BASE_URL', '') + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + await expect(apiRequest('/consents')).rejects.toThrow('VITE_API_BASE_URL is required') + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/portal/frontend/src/__tests__/AuthClient.test.ts b/portal/frontend/src/__tests__/AuthClient.test.ts new file mode 100644 index 00000000..72bf4dd4 --- /dev/null +++ b/portal/frontend/src/__tests__/AuthClient.test.ts @@ -0,0 +1,282 @@ +/* + * 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. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { apiRequest } from '../utils/apiClient' +import { + getAccessTokenPart1, + getUserProfile, + isAuthenticated, + isAuthEnabled, + login, + logout, + readCookie, + refreshSession, +} from '../utils/authClient' + +function setCookie(name: string, value: string): void { + document.cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; Path=/` +} + +function clearCookies(): void { + document.cookie.split(';').forEach((item) => { + const name = item.split('=')[0]?.trim() + if (name) { + document.cookie = `${name}=; Max-Age=0; Path=/` + } + }) +} + +afterEach(() => { + clearCookies() + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.unstubAllEnvs() +}) + +describe('portal auth client', () => { + it('reconstructs and decodes display-only ID-token cookies', () => { + const payload = btoa(JSON.stringify({ sub: 'user-1', name: 'Portal User' })) + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_') + const token = `header.${payload}.signature` + const midpoint = Math.floor(token.length / 2) + setCookie('portal-id-p1', token.slice(0, midpoint)) + setCookie('portal-id-p2', token.slice(midpoint)) + + expect(getUserProfile()).toMatchObject({ sub: 'user-1', name: 'Portal User' }) + expect(readCookie('portal-id-p1')).toBe(token.slice(0, midpoint)) + }) + + it.each([ + ['missing second half', 'header.payload.', undefined], + ['wrong segment count', 'only.two', 'halves'], + ['invalid base64url', 'header.%%%.signature', 'halves'], + ['invalid JSON', `header.${btoa('not-json')}.signature`, 'halves'], + ])('rejects malformed ID-token profile data: %s', (_name, part1, part2) => { + setCookie('portal-id-p1', part1) + if (part2) { + setCookie('portal-id-p2', part2) + } + + expect(getUserProfile()).toBeUndefined() + }) + + it('returns undefined for malformed cookie encoding', () => { + document.cookie = 'malformed=%E0%A4%A; Path=/' + + expect(readCookie('malformed')).toBeUndefined() + }) + + it('supports configured cookie names and authentication state', () => { + vi.stubEnv('VITE_AUTH_ENABLED', 'true') + vi.stubEnv('VITE_AUTH_ACCESS_TOKEN_PART1_COOKIE', 'custom-at') + setCookie('custom-at', 'custom-access') + + expect(isAuthEnabled()).toBe(true) + expect(getAccessTokenPart1()).toBe('custom-access') + expect(isAuthenticated()).toBe(true) + + clearCookies() + expect(isAuthenticated()).toBe(false) + + vi.stubEnv('VITE_AUTH_ENABLED', 'false') + expect(isAuthenticated()).toBe(true) + }) + + it('sends the readable refresh half with credentials and resets after failure', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://api.example') + vi.stubEnv('VITE_AUTH_REFRESH_TOKEN_PART1_COOKIE', 'custom-rt') + setCookie('custom-rt', 'refresh readable/part') + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 401 })) + .mockResolvedValueOnce(new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetchMock) + + await expect(refreshSession()).rejects.toThrow('session refresh failed') + await expect(refreshSession()).resolves.toBeUndefined() + + expect(fetchMock).toHaveBeenCalledTimes(2) + const [requestURL, requestInit] = fetchMock.mock.calls[1] ?? [] + expect(requestURL).toBe('http://api.example/auth/refresh') + expect(requestInit).toMatchObject({ + method: 'POST', + credentials: 'include', + body: 'refresh_token=refresh+readable%2Fpart', + }) + expect(new Headers(requestInit?.headers).get('Content-Type')).toBe( + 'application/x-www-form-urlencoded', + ) + }) + + it('does not call the refresh endpoint without the readable refresh half', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://api.example') + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + await expect(refreshSession()).rejects.toThrow('refresh token is unavailable') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('starts login at the BFF auth endpoint', () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://api.example/') + const assign = vi.fn() + vi.stubGlobal('window', { location: { assign } }) + + login() + + expect(assign).toHaveBeenCalledWith('http://api.example/auth/login') + }) + + it('starts login without calling logout when the access half is unavailable', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://api.example') + const fetchMock = vi.fn() + const assign = vi.fn() + vi.stubGlobal('fetch', fetchMock) + vi.stubGlobal('window', { location: { assign } }) + + await logout() + + expect(fetchMock).not.toHaveBeenCalled() + expect(assign).toHaveBeenCalledWith('http://api.example/auth/login') + }) + + it('logs out with the readable access half and navigates to the returned URL', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://api.example') + vi.stubEnv('VITE_AUTH_LOGOUT_ALLOWED_ORIGINS', 'https://idp.example') + setCookie('portal-at-p1', 'access-part') + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ logoutUrl: 'https://idp.example/logout' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + const assign = vi.fn() + vi.stubGlobal('window', { location: { assign } }) + + await logout() + + expect(fetchMock).toHaveBeenCalledOnce() + const [requestURL, requestInit] = fetchMock.mock.calls[0] ?? [] + expect(requestURL).toBe('http://api.example/auth/logout') + expect(requestInit).toMatchObject({ method: 'POST', credentials: 'include' }) + expect(new Headers(requestInit?.headers).get('Authorization')).toBe('Bearer access-part') + expect(assign).toHaveBeenCalledWith('https://idp.example/logout') + }) + + it('ignores malformed logout origins without discarding valid configured origins', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://api.example') + vi.stubEnv( + 'VITE_AUTH_LOGOUT_ALLOWED_ORIGINS', + 'not-a-url,ftp://unsupported.example,https://idp.example', + ) + setCookie('portal-at-p1', 'access-part') + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ logoutUrl: 'https://idp.example/logout' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + vi.stubGlobal('fetch', fetchMock) + const assign = vi.fn() + vi.stubGlobal('window', { location: { assign, origin: 'http://portal.example' } }) + + await logout() + + expect(fetchMock).toHaveBeenCalledOnce() + expect(assign).toHaveBeenCalledWith('https://idp.example/logout') + }) + + it('rejects a BFF-returned logout URL outside the navigation allowlist', async () => { + vi.stubEnv('VITE_API_BASE_URL', 'http://api.example') + vi.stubEnv('VITE_AUTH_LOGOUT_ALLOWED_ORIGINS', 'https://idp.example') + setCookie('portal-at-p1', 'access-part') + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ logoutUrl: 'https://attacker.example/logout' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ) + const assign = vi.fn() + vi.stubGlobal('window', { location: { assign, origin: 'http://portal.example' } }) + + await expect(logout()).rejects.toThrow('navigation URL origin is not allowed') + expect(assign).not.toHaveBeenCalled() + }) + + it.each([ + ['failed response', new Response(null, { status: 500 }), 'logout failed'], + [ + 'missing logout URL', + new Response(JSON.stringify({}), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + 'logout URL is unavailable', + ], + ])('rejects logout when the BFF returns a %s', async (_name, response, message) => { + vi.stubEnv('VITE_API_BASE_URL', 'http://api.example') + setCookie('portal-at-p1', 'access-part') + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response)) + + await expect(logout()).rejects.toThrow(message) + }) + + it('deduplicates concurrent refreshes and retries each request once', async () => { + vi.stubEnv('VITE_AUTH_ENABLED', 'true') + setCookie('portal-at-p1', 'old-access-part') + setCookie('portal-rt-p1', 'refresh-part') + + let apiCalls = 0 + let refreshCalls = 0 + const retryHeaders: string[] = [] + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const requestURL = String(input) + if (requestURL.endsWith('/auth/refresh')) { + refreshCalls += 1 + setCookie('portal-at-p1', 'new-access-part') + return new Response(null, { status: 204 }) + } + apiCalls += 1 + if (apiCalls <= 2) { + return new Response(JSON.stringify({ code: 'UNAUTHORIZED' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + retryHeaders.push(new Headers(init?.headers).get('Authorization') ?? '') + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }) + vi.stubGlobal('fetch', fetchMock) + + await Promise.all([apiRequest('/first'), apiRequest('/second')]) + + expect(refreshCalls).toBe(1) + expect(apiCalls).toBe(4) + expect(retryHeaders).toEqual(['Bearer new-access-part', 'Bearer new-access-part']) + }) +}) diff --git a/portal/frontend/src/__tests__/ContentSecurityPolicy.test.ts b/portal/frontend/src/__tests__/ContentSecurityPolicy.test.ts new file mode 100644 index 00000000..06ca9b10 --- /dev/null +++ b/portal/frontend/src/__tests__/ContentSecurityPolicy.test.ts @@ -0,0 +1,72 @@ +/* + * 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. + */ + +import { describe, expect, it } from 'vitest' +import { contentSecurityPolicy, staticHeadersFile } from '../security/contentSecurityPolicy' + +describe('frontend content security policy', () => { + it('restricts executable content and connects only to the exact BFF origin', () => { + const policy = contentSecurityPolicy({ + apiBaseURL: 'https://bff.example.com/api', + upgradeInsecureRequests: true, + }) + + expect(policy).toContain("default-src 'self'") + expect(policy).toContain("script-src 'self'") + expect(policy).toContain("connect-src 'self' https://bff.example.com") + expect(policy).toContain("object-src 'none'") + expect(policy).toContain("base-uri 'none'") + expect(policy).toContain("frame-ancestors 'none'") + expect(policy).toContain("form-action 'self'") + expect(policy).toContain('upgrade-insecure-requests') + expect(policy).not.toContain("script-src 'self' 'unsafe-inline'") + expect(policy).not.toContain("'unsafe-eval'") + }) + + it('keeps the temporary Emotion exception limited to styles', () => { + const policy = contentSecurityPolicy({ apiBaseURL: 'http://localhost:8080' }) + + expect(policy).toContain("style-src 'self' 'unsafe-inline'") + expect(policy).not.toContain('upgrade-insecure-requests') + }) + + it('omits header-only directives from the meta policy', () => { + const policy = contentSecurityPolicy({ + apiBaseURL: 'https://bff.example.com', + includeFrameAncestors: false, + }) + + expect(policy).not.toContain('frame-ancestors') + }) + + it('rejects non-HTTP BFF origins', () => { + expect(() => contentSecurityPolicy({ apiBaseURL: `javascript${':'}alert(1)` })).toThrow( + 'must use the http or https scheme', + ) + }) + + it('emits the enforced policy and companion security headers', () => { + const policy = contentSecurityPolicy({ apiBaseURL: 'https://bff.example.com' }) + const headers = staticHeadersFile(policy) + + expect(headers).toContain(`Content-Security-Policy: ${policy}`) + expect(headers).toContain('X-Content-Type-Options: nosniff') + expect(headers).toContain('X-Frame-Options: DENY') + expect(headers).toContain('Cross-Origin-Opener-Policy: same-origin') + }) +}) diff --git a/portal/frontend/src/__tests__/MainLayout.test.tsx b/portal/frontend/src/__tests__/MainLayout.test.tsx index f3cfef35..66d7cc7e 100644 --- a/portal/frontend/src/__tests__/MainLayout.test.tsx +++ b/portal/frontend/src/__tests__/MainLayout.test.tsx @@ -72,11 +72,11 @@ function renderHeaderBreadcrumbs(initialRoute: string): void { } describe('MainLayout', () => { - it('renders translated header title and avatar aria label', () => { + it('renders translated header title and account menu trigger', () => { renderMainLayout() expect(screen.getByText('Consent Portal')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Signed-in user avatar' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Account' })).toBeInTheDocument() }) it('toggles sidebar collapsed state when header toggle is clicked', () => { diff --git a/portal/frontend/src/__tests__/UserProfileMenu.test.tsx b/portal/frontend/src/__tests__/UserProfileMenu.test.tsx new file mode 100644 index 00000000..89f07985 --- /dev/null +++ b/portal/frontend/src/__tests__/UserProfileMenu.test.tsx @@ -0,0 +1,147 @@ +/* + * 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. + */ + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { AcrylicOrangeTheme, OxygenUIThemeProvider } from '@wso2/oxygen-ui' +import { I18nextProvider } from 'react-i18next' +import { afterEach, describe, expect, it, vi } from 'vitest' +import UserProfileMenu from '../components/layout/main-layout/UserProfileMenu' +import i18n from '../i18n/i18n' + +const authMocks = vi.hoisted(() => ({ + getUserProfile: vi.fn<() => Record | undefined>(), + logout: vi.fn<() => Promise>(), +})) + +vi.mock('../utils/authClient', () => authMocks) + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +function renderMenu(profile?: Record): void { + authMocks.getUserProfile.mockReturnValue(profile) + authMocks.logout.mockResolvedValue() + render( + + + + + , + ) + fireEvent.click(screen.getByRole('button', { name: 'Account' })) +} + +describe('UserProfileMenu', () => { + it.each([ + [{ name: 'Name', displayName: 'Display Name' }, 'Name'], + [{ displayName: 'Display Name' }, 'Display Name'], + [{ given_name: 'Ada', family_name: 'Lovelace' }, 'Ada Lovelace'], + [{ preferred_username: 'preferred', username: 'username' }, 'preferred'], + [{ username: 'username' }, 'username'], + ])('resolves the display name from claims in priority order', (profile, expectedName) => { + renderMenu(profile) + + expect(screen.getByText(expectedName)).toBeInTheDocument() + }) + + it('uses email and avatar claims', () => { + renderMenu({ + name: 'Portal User', + email: 'user@example.com', + picture: 'https://example.com/u.png', + }) + + expect(screen.getByText('user@example.com')).toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Portal User' })).toHaveAttribute( + 'src', + 'https://example.com/u.png', + ) + }) + + it('falls back to subject when email is unavailable', () => { + renderMenu({ name: 'Portal User', sub: 'user-1' }) + + expect(screen.getByText('user-1')).toBeInTheDocument() + }) + + it('shows translated fallbacks when profile claims are unavailable', () => { + renderMenu() + + expect(screen.getByText('Unknown user')).toBeInTheDocument() + expect(screen.getByText('No email available')).toBeInTheDocument() + }) + + it('renders untrusted profile claims as text rather than executable markup', () => { + const payload = '' + renderMenu({ name: payload, email: '' }) + + expect(screen.getByText(payload)).toBeInTheDocument() + expect(screen.getByText('')).toBeInTheDocument() + expect(document.querySelector('script')).not.toBeInTheDocument() + expect(document.querySelector('img[src="x"]')).not.toBeInTheDocument() + }) + + it('shows a translated logout error and allows retry', async () => { + renderMenu({ name: 'Portal User', email: 'user@example.com' }) + authMocks.logout.mockRejectedValueOnce(new Error('logout failed')).mockResolvedValueOnce() + + fireEvent.click(screen.getByRole('menuitem', { name: 'Sign out' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('Unable to sign out. Please try again.') + expect(authMocks.logout).toHaveBeenCalledOnce() + + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + + await waitFor(() => expect(authMocks.logout).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + }) + + it('preserves the displayed profile while logout clears token cookies', async () => { + let completeLogout: (() => void) | undefined + authMocks.getUserProfile.mockReturnValue({ + name: 'Portal User', + email: 'user@example.com', + }) + authMocks.logout.mockReturnValue( + new Promise((resolve) => { + completeLogout = resolve + }), + ) + render( + + + + + , + ) + fireEvent.click(screen.getByRole('button', { name: 'Account' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Sign out' })) + + authMocks.getUserProfile.mockReturnValue(undefined) + fireEvent.click(screen.getByRole('button', { name: 'Account' })) + + expect(screen.getByText('Portal User')).toBeInTheDocument() + expect(screen.queryByText('Unknown user')).not.toBeInTheDocument() + + completeLogout?.() + await waitFor(() => expect(authMocks.logout).toHaveBeenCalledOnce()) + }) +}) diff --git a/portal/frontend/src/components/layout/main-layout/MainLayout.tsx b/portal/frontend/src/components/layout/main-layout/MainLayout.tsx index 94f5763b..8a7ef46c 100644 --- a/portal/frontend/src/components/layout/main-layout/MainLayout.tsx +++ b/portal/frontend/src/components/layout/main-layout/MainLayout.tsx @@ -16,12 +16,12 @@ * under the License. */ -import { AppShell, Box, ColorSchemeToggle, Header, IconButton } from '@wso2/oxygen-ui' -import { CircleUserRound } from '@wso2/oxygen-ui-icons-react' +import { AppShell, Box, ColorSchemeToggle, Header } from '@wso2/oxygen-ui' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Outlet } from 'react-router-dom' import AppSidebar from '../sidebar/AppSidebar' +import UserProfileMenu from './UserProfileMenu' function MainLayout(): React.JSX.Element { const { t } = useTranslation('common') @@ -56,9 +56,7 @@ function MainLayout(): React.JSX.Element { - - - + diff --git a/portal/frontend/src/components/layout/main-layout/UserProfileMenu.tsx b/portal/frontend/src/components/layout/main-layout/UserProfileMenu.tsx new file mode 100644 index 00000000..bec34784 --- /dev/null +++ b/portal/frontend/src/components/layout/main-layout/UserProfileMenu.tsx @@ -0,0 +1,99 @@ +/* + * 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. + */ + +import { Alert, Button, Snackbar, UserMenu } from '@wso2/oxygen-ui' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { getUserProfile, logout } from '../../../utils/authClient' + +type UserClaims = Record + +function claim(claims: UserClaims, name: string): string | undefined { + const value = claims[name] + + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function displayName(claims: UserClaims, fallback: string): string { + const name = claim(claims, 'name') ?? claim(claims, 'displayName') + if (name) { + return name + } + + const fullName = [claim(claims, 'given_name'), claim(claims, 'family_name')] + .filter(Boolean) + .join(' ') + + return fullName || claim(claims, 'preferred_username') || claim(claims, 'username') || fallback +} + +function UserProfileMenu(): React.JSX.Element { + const { t } = useTranslation('common') + const [logoutFailed, setLogoutFailed] = useState(false) + const [logoutPending, setLogoutPending] = useState(false) + const [claims] = useState(() => getUserProfile() ?? {}) + const name = displayName(claims, t('layout.userMenu.unknownUser')) + const email = claim(claims, 'email') ?? claim(claims, 'sub') ?? t('layout.userMenu.noEmail') + const avatar = claim(claims, 'picture') + + const handleLogout = async (): Promise => { + if (logoutPending) { + return + } + + setLogoutFailed(false) + setLogoutPending(true) + try { + await logout() + } catch { + setLogoutFailed(true) + } finally { + setLogoutPending(false) + } + } + + return ( + <> + + + + + + + setLogoutFailed(false)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + + {t('layout.userMenu.tryAgain')} + + } + > + {t('layout.userMenu.signOutError')} + + + + ) +} + +export default UserProfileMenu diff --git a/portal/frontend/src/i18n/resources/en/common.ts b/portal/frontend/src/i18n/resources/en/common.ts index ca304621..09051ca6 100644 --- a/portal/frontend/src/i18n/resources/en/common.ts +++ b/portal/frontend/src/i18n/resources/en/common.ts @@ -30,7 +30,13 @@ const commonEn = { layout: { home: 'Home', breadcrumbAriaLabel: 'Breadcrumb', - userAvatarAriaLabel: 'Signed-in user avatar', + userMenu: { + unknownUser: 'Unknown user', + noEmail: 'No email available', + signOut: 'Sign out', + signOutError: 'Unable to sign out. Please try again.', + tryAgain: 'Try again', + }, }, dashboard: { title: 'Dashboard', diff --git a/portal/frontend/src/security/contentSecurityPolicy.ts b/portal/frontend/src/security/contentSecurityPolicy.ts new file mode 100644 index 00000000..52814944 --- /dev/null +++ b/portal/frontend/src/security/contentSecurityPolicy.ts @@ -0,0 +1,75 @@ +/* + * 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. + */ + +export interface ContentSecurityPolicyOptions { + apiBaseURL: string + includeFrameAncestors?: boolean + upgradeInsecureRequests?: boolean +} + +function httpOrigin(value: string): string { + const url = new URL(value) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('VITE_API_BASE_URL must use the http or https scheme.') + } + return url.origin +} + +/** Builds the production portal CSP from exact deployment origins. */ +export function contentSecurityPolicy(options: ContentSecurityPolicyOptions): string { + const directives = [ + "default-src 'self'", + "script-src 'self'", + // Oxygen UI uses Emotion to create runtime style elements. Replace this + // exception with a style nonce when the static host supports per-request HTML. + "style-src 'self' 'unsafe-inline'", + `connect-src 'self' ${httpOrigin(options.apiBaseURL)}`, + "img-src 'self' data:", + "font-src 'self'", + "object-src 'none'", + "base-uri 'none'", + "form-action 'self'", + "manifest-src 'self'", + ] + + if (options.includeFrameAncestors !== false) { + directives.push("frame-ancestors 'none'") + } + if (options.upgradeInsecureRequests) { + directives.push('upgrade-insecure-requests') + } + + return `${directives.join('; ')};` +} + +/** Renders a deployment artifact understood by static hosts supporting `_headers`. */ +export function staticHeadersFile(policy: string): string { + return [ + '/*', + ` Content-Security-Policy: ${policy}`, + ' X-Content-Type-Options: nosniff', + ' X-Frame-Options: DENY', + ' Referrer-Policy: strict-origin-when-cross-origin', + ' Permissions-Policy: camera=(), geolocation=(), microphone=()', + ' Cross-Origin-Opener-Policy: same-origin', + '', + '/index.html', + ' Cache-Control: no-cache', + '', + ].join('\n') +} diff --git a/portal/frontend/src/utils/apiClient.ts b/portal/frontend/src/utils/apiClient.ts index 6557940d..cb8a44f5 100644 --- a/portal/frontend/src/utils/apiClient.ts +++ b/portal/frontend/src/utils/apiClient.ts @@ -16,6 +16,8 @@ * under the License. */ +import { getAccessTokenPart1, isAuthEnabled, login, refreshSession } from './authClient' + export interface APIErrorPayload { code?: string message?: string @@ -45,6 +47,11 @@ function buildHeaders(headers?: HeadersInit): Headers { normalizedHeaders.set('Accept', 'application/json') } + const accessTokenPart = getAccessTokenPart1() + if (accessTokenPart && !normalizedHeaders.has('Authorization')) { + normalizedHeaders.set('Authorization', `Bearer ${accessTokenPart}`) + } + return normalizedHeaders } @@ -78,12 +85,32 @@ function buildURL(path: string, query?: RequestOptions['query']): string { return url.toString() } +async function createAPIError(response: Response): Promise { + let payload: APIErrorPayload | undefined + + try { + payload = (await response.json()) as APIErrorPayload + } catch { + payload = undefined + } + + return new APIError( + response.status, + payload?.code ?? 'API_REQUEST_FAILED', + payload?.message ?? `request failed with status ${response.status}`, + ) +} + /** * Sends an API request and parses the response body as JSON. * * Use this helper for endpoints that always return a JSON payload. */ -export async function apiRequest(path: string, options: RequestOptions = {}): Promise { +async function apiRequestInternal( + path: string, + options: RequestOptions, + allowRefresh: boolean, +): Promise { const { query, headers, ...requestInit } = options const response = await fetch(buildURL(path, query), { credentials: 'include', @@ -91,20 +118,21 @@ export async function apiRequest(path: string, options: RequestOptions = {}): headers: buildHeaders(headers), }) - if (!response.ok) { - let payload: APIErrorPayload | undefined - + if (response.status === 401 && allowRefresh && isAuthEnabled()) { try { - payload = (await response.json()) as APIErrorPayload + await refreshSession() } catch { - payload = undefined + login() + throw await createAPIError(response) } + return apiRequestInternal(path, options, false) + } + if (response.status === 401 && !allowRefresh && isAuthEnabled()) { + login() + } - throw new APIError( - response.status, - payload?.code ?? 'API_REQUEST_FAILED', - payload?.message ?? `request failed with status ${response.status}`, - ) + if (!response.ok) { + throw await createAPIError(response) } if (response.status === 204) { @@ -117,15 +145,20 @@ export async function apiRequest(path: string, options: RequestOptions = {}): return (await response.json()) as T } +export async function apiRequest(path: string, options: RequestOptions = {}): Promise { + return apiRequestInternal(path, options, true) +} + /** * Sends an API request for endpoints that are expected to return no content. * * This helper is intended for APIs that commonly respond with HTTP 204. * If a successful response includes a payload unexpectedly, it is ignored. */ -export async function apiRequestNoContent( +async function apiRequestNoContentInternal( path: string, - options: RequestOptions = {}, + options: RequestOptions, + allowRefresh: boolean, ): Promise { const { query, headers, ...requestInit } = options const response = await fetch(buildURL(path, query), { @@ -134,19 +167,28 @@ export async function apiRequestNoContent( headers: buildHeaders(headers), }) - if (!response.ok) { - let payload: APIErrorPayload | undefined - + if (response.status === 401 && allowRefresh && isAuthEnabled()) { try { - payload = (await response.json()) as APIErrorPayload + await refreshSession() } catch { - payload = undefined + login() + throw await createAPIError(response) } + await apiRequestNoContentInternal(path, options, false) + return + } + if (response.status === 401 && !allowRefresh && isAuthEnabled()) { + login() + } - throw new APIError( - response.status, - payload?.code ?? 'API_REQUEST_FAILED', - payload?.message ?? `request failed with status ${response.status}`, - ) + if (!response.ok) { + throw await createAPIError(response) } } + +export async function apiRequestNoContent( + path: string, + options: RequestOptions = {}, +): Promise { + return apiRequestNoContentInternal(path, options, true) +} diff --git a/portal/frontend/src/utils/authClient.ts b/portal/frontend/src/utils/authClient.ts new file mode 100644 index 00000000..7f61910c --- /dev/null +++ b/portal/frontend/src/utils/authClient.ts @@ -0,0 +1,187 @@ +/* + * 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. + */ + +export type UserProfile = Record + +interface LogoutResponse { + logoutUrl: string +} + +let refreshPromise: Promise | undefined + +function envCookieName(key: string, fallback: string): string { + return (import.meta.env[key] as string | undefined) || fallback +} + +function apiURL(path: string): string { + const baseURL = import.meta.env.VITE_API_BASE_URL + if (!baseURL) { + throw new Error('VITE_API_BASE_URL is required for authentication.') + } + const normalizedBase = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL + return `${normalizedBase}${path}` +} + +function httpURL(value: string): URL { + const url = new URL(value) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error('navigation URL must use http or https') + } + return url +} + +function allowedNavigationOrigins(): Set { + const origins = new Set([httpURL(apiURL('/')).origin]) + if (window.location.origin) { + origins.add(window.location.origin) + } + + const configured = import.meta.env.VITE_AUTH_LOGOUT_ALLOWED_ORIGINS as string | undefined + const configuredOrigins = configured + ?.split(',') + .map((origin) => origin.trim()) + .filter(Boolean) + + configuredOrigins?.forEach((origin) => { + try { + origins.add(httpURL(origin).origin) + } catch { + // Invalid configured entries fail closed without disabling valid origins. + } + }) + + return origins +} + +function navigate(value: string): void { + const url = httpURL(value) + if (!allowedNavigationOrigins().has(url.origin)) { + throw new Error('navigation URL origin is not allowed') + } + window.location.assign(url.toString()) +} + +export function isAuthEnabled(): boolean { + return import.meta.env.VITE_AUTH_ENABLED === 'true' +} + +export function readCookie(name: string): string | undefined { + const prefix = `${encodeURIComponent(name)}=` + const value = document.cookie + .split(';') + .map((item) => item.trim()) + .find((item) => item.startsWith(prefix)) + if (value) { + try { + return decodeURIComponent(value.slice(prefix.length)) + } catch { + return undefined + } + } + return undefined +} + +export function getAccessTokenPart1(): string | undefined { + return readCookie(envCookieName('VITE_AUTH_ACCESS_TOKEN_PART1_COOKIE', 'portal-at-p1')) +} + +function getRefreshTokenPart1(): string | undefined { + return readCookie(envCookieName('VITE_AUTH_REFRESH_TOKEN_PART1_COOKIE', 'portal-rt-p1')) +} + +function getIDToken(): string | undefined { + const part1 = readCookie(envCookieName('VITE_AUTH_ID_TOKEN_PART1_COOKIE', 'portal-id-p1')) + const part2 = readCookie(envCookieName('VITE_AUTH_ID_TOKEN_PART2_COOKIE', 'portal-id-p2')) + return part1 && part2 ? part1 + part2 : undefined +} + +export function isAuthenticated(): boolean { + return !isAuthEnabled() || Boolean(getAccessTokenPart1()) +} + +export function getUserProfile(): UserProfile | undefined { + const idToken = getIDToken() + if (!idToken) { + return undefined + } + const segments = idToken.split('.') + if (segments.length !== 3) { + return undefined + } + try { + const normalized = segments[1].replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=') + const bytes = Uint8Array.from(atob(padded), (character) => character.charCodeAt(0)) + return JSON.parse(new TextDecoder().decode(bytes)) as UserProfile + } catch { + return undefined + } +} + +export function login(): void { + navigate(apiURL('/auth/login')) +} + +export function refreshSession(): Promise { + if (refreshPromise) { + return refreshPromise + } + refreshPromise = (async () => { + const refreshPart = getRefreshTokenPart1() + if (!refreshPart) { + throw new Error('refresh token is unavailable') + } + const body = new URLSearchParams({ refresh_token: refreshPart }) + const response = await fetch(apiURL('/auth/refresh'), { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + if (!response.ok) { + throw new Error('session refresh failed') + } + })().finally(() => { + refreshPromise = undefined + }) + return refreshPromise +} + +export async function logout(): Promise { + const accessPart = getAccessTokenPart1() + if (!accessPart) { + login() + return + } + const response = await fetch(apiURL('/auth/logout'), { + method: 'POST', + credentials: 'include', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${accessPart}`, + }, + }) + if (!response.ok) { + throw new Error('logout failed') + } + const payload = (await response.json()) as LogoutResponse + if (!payload.logoutUrl) { + throw new Error('logout URL is unavailable') + } + navigate(payload.logoutUrl) +} diff --git a/portal/frontend/vite.config.ts b/portal/frontend/vite.config.ts index 4c0298d8..5e1ff5da 100644 --- a/portal/frontend/vite.config.ts +++ b/portal/frontend/vite.config.ts @@ -18,18 +18,65 @@ import react from '@vitejs/plugin-react' import { loadEnv } from 'vite' +import type { Plugin } from 'vite' import { defineConfig } from 'vitest/config' +import { contentSecurityPolicy, staticHeadersFile } from './src/security/contentSecurityPolicy' + +function securityHeadersPlugin(policy: string, metaPolicy: string): Plugin { + return { + name: 'portal-security-headers', + transformIndexHtml: { + order: 'pre', + handler() { + return [ + { + tag: 'meta', + attrs: { 'http-equiv': 'Content-Security-Policy', content: metaPolicy }, + injectTo: 'head-prepend', + }, + ] + }, + }, + generateBundle() { + this.emitFile({ type: 'asset', fileName: '_headers', source: staticHeadersFile(policy) }) + }, + } +} // https://vite.dev/config/ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), 'VITE_') + const apiBaseURL = env.VITE_API_BASE_URL || 'http://localhost:8080' if (mode !== 'test' && !env.VITE_API_BASE_URL) { throw new Error('VITE_API_BASE_URL is required. Create a .env file from .env.example.') } + const production = mode === 'production' + const upgradeInsecureRequests = production && apiBaseURL.startsWith('https://') + const policy = contentSecurityPolicy({ + apiBaseURL, + upgradeInsecureRequests, + }) + // frame-ancestors is supported only in the HTTP header, not a CSP meta element. + const metaPolicy = contentSecurityPolicy({ + apiBaseURL, + includeFrameAncestors: false, + upgradeInsecureRequests, + }) + return { - plugins: [react()], + plugins: [react(), ...(production ? [securityHeadersPlugin(policy, metaPolicy)] : [])], + preview: { + headers: { + 'Content-Security-Policy': policy, + 'Cross-Origin-Opener-Policy': 'same-origin', + 'Permissions-Policy': 'camera=(), geolocation=(), microphone=()', + 'Referrer-Policy': 'strict-origin-when-cross-origin', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + }, + }, test: { environment: 'jsdom', setupFiles: './vitest.setup.ts',