diff --git a/.github/workflows/portal-backend-ci.yml b/.github/workflows/portal-backend-ci.yml new file mode 100644 index 00000000..035f8dae --- /dev/null +++ b/.github/workflows/portal-backend-ci.yml @@ -0,0 +1,44 @@ +name: Portal Backend CI + +on: + pull_request: + paths: + - 'portal/backend/**' + - '.github/workflows/portal-backend-ci.yml' + push: + branches: [ main ] + paths: + - 'portal/backend/**' + - '.github/workflows/portal-backend-ci.yml' + +jobs: + portal-backend-ci: + runs-on: ubuntu-latest + defaults: + run: + working-directory: portal/backend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: portal/backend/go.mod + + - name: Download deps + run: go mod download + + - name: Format check + run: | + test -z "$(gofmt -l . | tee /dev/stderr)" + + - name: golangci-lint + uses: golangci/golangci-lint-action@v9 + with: + working-directory: portal/backend + version: v2.12.2 + + - name: Run tests + run: go test ./... -v diff --git a/portal/backend/.env.example b/portal/backend/.env.example new file mode 100644 index 00000000..7018b559 --- /dev/null +++ b/portal/backend/.env.example @@ -0,0 +1,35 @@ +# Optional config file path. If unset, defaults are used and env overrides apply. +BFF_CONFIG_FILE= + +# Server +BFF_SERVER__HOST=0.0.0.0 +BFF_SERVER__PORT=8080 +BFF_SERVER__READ_TIMEOUT=15s +BFF_SERVER__WRITE_TIMEOUT=15s +BFF_SERVER__IDLE_TIMEOUT=60s +BFF_SERVER__SHUTDOWN_TIMEOUT=10s + +# Logging +BFF_LOG__LEVEL=info + +# Environment +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 + +# Proxy +BFF_PROXY__OPENFGC_API_URL=http://localhost:9090 +BFF_PROXY__OPENFGC_API_TIMEOUT=10s +BFF_PROXY__MAX_REQUEST_BYTES=1048576 +BFF_PROXY__MAX_RESPONSE_BYTES=10485760 +BFF_PROXY__ALLOWED_PASSTHROUGH_METHODS=["GET","POST","PUT","DELETE"] + +# Placeholder identity mode (Phase 2 only; must be false in production) +BFF_PROXY__PLACEHOLDER_MODE_ENABLED=false +BFF_PROXY__PLACEHOLDER_USER_ID= +BFF_PROXY__PLACEHOLDER_ORG_ID= +BFF_PROXY__PLACEHOLDER_CLIENT_ID= diff --git a/portal/backend/.gitignore b/portal/backend/.gitignore new file mode 100644 index 00000000..2ac7af56 --- /dev/null +++ b/portal/backend/.gitignore @@ -0,0 +1,28 @@ +# Go build outputs +bin/ +*.exe +*.dll +*.so +*.dylib +*.test +*.out + +# Coverage outputs +coverage.out +coverage.txt +*.cov + +# Local environment/config files +.env +.env.local +local.yaml +local.yml + +# IDE/editor files +.vscode/ +.idea/ +*.swp + +# OS files +.DS_Store +Thumbs.db diff --git a/portal/backend/.golangci.yml b/portal/backend/.golangci.yml new file mode 100644 index 00000000..4a116b4a --- /dev/null +++ b/portal/backend/.golangci.yml @@ -0,0 +1,15 @@ +version: "2" + +run: + timeout: 3m + tests: true + +linters: + enable: + - govet + - staticcheck + - revive + +formatters: + enable: + - gofmt diff --git a/portal/backend/Dockerfile b/portal/backend/Dockerfile new file mode 100644 index 00000000..3d278900 --- /dev/null +++ b/portal/backend/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.26.3 AS builder +WORKDIR /app +COPY . . +RUN go mod download && go build -o /out/bff ./cmd/server + +FROM gcr.io/distroless/base-debian12:nonroot +WORKDIR /app +COPY --from=builder /out/bff /app/bff +USER 65532:65532 +EXPOSE 8080 +ENTRYPOINT ["/app/bff"] diff --git a/portal/backend/README.md b/portal/backend/README.md new file mode 100644 index 00000000..304ec669 --- /dev/null +++ b/portal/backend/README.md @@ -0,0 +1,110 @@ + + +# OpenFGC Portal Backend (BFF) + +Stateless backend-for-frontend (BFF) for OpenFGC Portal, responsible for handling portal-facing authentication flows and securely proxying API requests from `portal/frontend` to `/consent-server`. + +## Commands + +- `task fmt` +- `task fmt:check` (no edits. check only) +- `task lint` +- `task lint:install` (optional, installs golangci-lint to GOPATH/bin) +- `task test` +- `task build` +- `task run` +- `task run:env` (loads variables from `.env` for local development) + +Install Task if needed: https://taskfile.dev/installation/ + +## Configuration + +- Primary source: `BFF_` environment variables +- Optional file overlay: set `BFF_CONFIG_FILE` to a YAML config file path +- Final effective config is: defaults < file < env + +### CORS for local frontend + +When running `portal/frontend` on a different origin (for example Vite dev server), allow that origin with: + +- `BFF_CORS__ALLOWED_ORIGINS` (comma-separated origins) +- `BFF_CORS__ALLOWED_METHODS` (comma-separated methods) +- `BFF_CORS__ALLOWED_HEADERS` (comma-separated request headers) +- `BFF_CORS__ALLOW_CREDENTIALS` (`true`/`false`) + +Requests that include an `Origin` header from a non-allowlisted origin are rejected. +When credentials are enabled, origins must be explicitly allowlisted (wildcard origin is rejected). + +## Health endpoints + +- `GET /health` +- `GET /health/liveness` +- `GET /health/readiness` + +## API endpoints + +Portal-facing user endpoints: + +- `GET /me/consents` -> upstream `GET /api/v1/consents` with forced `userIds=` +- `GET /me/consents/{consentId}` -> upstream `GET /api/v1/consents/{consentId}` +- `POST /me/consents/{consentId}/approve` -> BFF fetches current consent, merges selected optional approvals, uses the consent's `clientId` as the trusted upstream `TPP-client-id`, updates an existing authorization to approved for the trusted user (or creates one if none exist), and upstreams `PUT /api/v1/consents/{consentId}` +- `PUT /me/consents/{consentId}/revoke` -> upstream `PUT /api/v1/consents/{consentId}/revoke` + +Proxy hardening: + +- Path rewrite `/api/*` -> `/api/v1/*` with query preservation +- Deny-by-default allowlist for consent-server routes (unknown path -> `404`, known path wrong method -> `405`) +- Hop-by-hop header stripping and trusted-header override prevention (`org-id`, `TPP-client-id`) +- Correlation ID propagation/generation via `X-Correlation-ID` +- Request body limit enforcement (`BFF_PROXY__MAX_REQUEST_BYTES`) with `413` +- Deterministic upstream error mapping: timeout -> `504`, other connectivity failures -> `502` + +Error contract for proxy-originated failures: + +```json +{ + "code": "REQUEST_TOO_LARGE", + "message": "request entity too large" +} +``` + +Common error codes: + +- `REQUEST_TOO_LARGE` +- `METHOD_NOT_ALLOWED` +- `NOT_FOUND` +- `INVALID_PAYLOAD` +- `UPSTREAM_TIMEOUT` +- `UPSTREAM_UNAVAILABLE` + +## Placeholder mode guardrails + +- `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 + +## AI Instructions + +This repository uses VS Code Copilot instruction files to keep AI-generated changes aligned with project and organization standards. + +- Backend standards: `portal/backend/AGENTS.md` +- Copilot workspace entrypoint (repo root): `.github/copilot-instructions.md` +- Scoped instructions folder (repo root): `.github/instructions/` +- Backend scope mapping: `portal/backend/**` -> `.github/instructions/portal-backend.instructions.md` + +Copilot instructions are discovered automatically and scoped using `applyTo` patterns. diff --git a/portal/backend/Taskfile.yml b/portal/backend/Taskfile.yml new file mode 100644 index 00000000..f1a631f3 --- /dev/null +++ b/portal/backend/Taskfile.yml @@ -0,0 +1,47 @@ +version: '3' + +vars: + GOLANGCI_LINT_VERSION: v2.12.2 + +tasks: + fmt: + desc: Format Go source files + cmds: + - gofmt -w cmd internal tests + + fmt:check: + desc: Check formatting without modifying files + cmds: + - gofmt -l cmd internal tests + + lint: + desc: Run golangci-lint (no global install required) + cmds: + - go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@{{.GOLANGCI_LINT_VERSION}} run ./... + + lint:install: + desc: Install golangci-lint to GOPATH/bin (optional) + cmds: + - go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@{{.GOLANGCI_LINT_VERSION}} + + test: + desc: Run all tests + cmds: + - go test ./... -v + + build: + desc: Build the BFF binary + cmds: + - go build -o bin/portal-backend{{if eq OS "windows"}}.exe{{end}} ./cmd/server + + run: + desc: Run the BFF server + cmds: + - go run ./cmd/server + + run:env: + desc: Run the BFF server with variables loaded from .env + dotenv: + - .env + cmds: + - go run ./cmd/server diff --git a/portal/backend/cmd/server/main.go b/portal/backend/cmd/server/main.go new file mode 100644 index 00000000..abd5100b --- /dev/null +++ b/portal/backend/cmd/server/main.go @@ -0,0 +1,117 @@ +/* + * 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 main starts the OpenFGC portal backend BFF service. +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/wso2/openfgc/portal/backend/internal/system/config" + systemlog "github.com/wso2/openfgc/portal/backend/internal/system/log" + "github.com/wso2/openfgc/portal/backend/internal/system/middleware" +) + +func main() { + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err) + os.Exit(1) + } + + log := systemlog.New(cfg.Log.Level) + if cfg.Proxy.PlaceholderModeEnabled { + log.Warn("placeholder identity mode is enabled; do not use in production") + } + handler, err := newHTTPHandler(log, *cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to initialize handler: %v\n", err) + os.Exit(1) + } + + srv := &http.Server{ + Addr: fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port), + Handler: handler, + ReadTimeout: cfg.Server.ReadTimeout, + WriteTimeout: cfg.Server.WriteTimeout, + IdleTimeout: cfg.Server.IdleTimeout, + } + + serveErrCh := make(chan error, 1) + + go func() { + log.Info("starting OpenFGC portal backend server", "addr", srv.Addr) + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + serveErrCh <- err + } + }() + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + exitCode := 0 + + select { + case <-ctx.Done(): + case err := <-serveErrCh: + log.Error("server stopped unexpectedly", "error", err) + exitCode = 1 + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout) + defer cancel() + + log.Info("shutting down OpenFGC portal backend server") + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Error("graceful shutdown failed", "error", err) + exitCode = 1 + } + + // Give the logger a brief window to flush the final shutdown line. + time.Sleep(50 * time.Millisecond) + log.Info("OpenFGC portal backend server stopped") + + if exitCode != 0 { + os.Exit(exitCode) + } +} + +func newHTTPHandler(log *slog.Logger, cfg config.Config) (http.Handler, error) { + mux := http.NewServeMux() + + if err := registerServices(mux, log, cfg); err != nil { + return nil, err + } + + withCORS := middleware.CORS(mux, middleware.CORSOptions{ + AllowedOrigins: cfg.CORS.AllowedOrigins, + AllowedMethods: cfg.CORS.AllowedMethods, + AllowedHeaders: cfg.CORS.AllowedHeaders, + AllowCredentials: cfg.CORS.AllowCredentials, + }) + + return middleware.CorrelationID(log, withCORS), nil +} diff --git a/portal/backend/cmd/server/servicemanager.go b/portal/backend/cmd/server/servicemanager.go new file mode 100644 index 00000000..21afb452 --- /dev/null +++ b/portal/backend/cmd/server/servicemanager.go @@ -0,0 +1,49 @@ +/* + * 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 main + +import ( + "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/config" + "github.com/wso2/openfgc/portal/backend/internal/system/healthcheck" +) + +func registerServices(mux *http.ServeMux, log *slog.Logger, cfg config.Config) error { + healthHandler := healthcheck.NewHandler() + mux.HandleFunc("GET /health/liveness", healthHandler.Liveness) + mux.HandleFunc("GET /health/readiness", healthHandler.Readiness) + mux.HandleFunc("GET /health", healthHandler.Liveness) + log.Debug("registered health endpoints") + + if err := proxy.Initialize(mux, cfg.Proxy); err != nil { + return err + } + log.Debug("registered proxy module") + + if err := me.Initialize(mux, cfg); err != nil { + return err + } + log.Debug("registered me module") + + return nil +} diff --git a/portal/backend/docker-compose.yml b/portal/backend/docker-compose.yml new file mode 100644 index 00000000..7382e19e --- /dev/null +++ b/portal/backend/docker-compose.yml @@ -0,0 +1,11 @@ +services: + bff: + build: + context: . + dockerfile: Dockerfile + env_file: + # Load local environment overrides when present. + - path: .env + required: false + ports: + - "8080:8080" diff --git a/portal/backend/go.mod b/portal/backend/go.mod new file mode 100644 index 00000000..d6b2d4f3 --- /dev/null +++ b/portal/backend/go.mod @@ -0,0 +1,20 @@ +module github.com/wso2/openfgc/portal/backend + +go 1.26.3 + +require ( + 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 +) + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/knadh/koanf/maps v0.1.2 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + go.yaml.in/yaml/v3 v3.0.3 // indirect + golang.org/x/sys v0.32.0 // indirect +) diff --git a/portal/backend/go.sum b/portal/backend/go.sum new file mode 100644 index 00000000..b061e312 --- /dev/null +++ b/portal/backend/go.sum @@ -0,0 +1,36 @@ +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-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= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= +github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= +github.com/knadh/koanf/providers/env v1.0.0 h1:ufePaI9BnWH+ajuxGGiJ8pdTG0uLEUWC7/HDDPGLah0= +github.com/knadh/koanf/providers/env v1.0.0/go.mod h1:mzFyRZueYhb37oPmC1HAv/oGEEuyvJDA98r3XAa8Gak= +github.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmYIDXI7OJU6+U= +github.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA= +github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM= +github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +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/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= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/portal/backend/internal/me/handler.go b/portal/backend/internal/me/handler.go new file mode 100644 index 00000000..59a22ae7 --- /dev/null +++ b/portal/backend/internal/me/handler.go @@ -0,0 +1,261 @@ +/* + * 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 me contains user-facing aggregated /me endpoints for the BFF. +package me + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/wso2/openfgc/portal/backend/internal/proxy" + "github.com/wso2/openfgc/portal/backend/internal/system/config" + systemcontext "github.com/wso2/openfgc/portal/backend/internal/system/context" +) + +// Handler serves /me route groups. +type Handler struct { + svc *Service + cfg config.ProxyConfig +} + +type errorResponse struct { + Code string `json:"code"` + Message string `json:"message"` +} + +var errRequestBodyTooLarge = errors.New("request body too large") +var consentIDPattern = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +// NewHandler creates a me handler with initialized service. +func NewHandler(cfg config.ProxyConfig) (*Handler, error) { + svc, err := NewService(cfg) + if err != nil { + return nil, err + } + return &Handler{svc: svc, cfg: cfg}, nil +} + +// Consents handles GET /me/consents. +func (h *Handler) Consents(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed") + return + } + userID, ok := h.resolveUserID(w, r) + if !ok { + return + } + if err := h.svc.Forward(w, r, http.MethodGet, "/api/v1/consents", func(q url.Values) { + q.Set("userIds", userID) + }, nil); err != nil { + writeProxyError(w, err) + } +} + +// ConsentByID handles GET /me/consents/{consentId}. +func (h *Handler) ConsentByID(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeJSONError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed") + return + } + if _, ok := h.resolveUserID(w, r); !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") + return + } + if !isValidConsentID(consentID) { + writeJSONError(w, http.StatusBadRequest, "INVALID_CONSENT_ID", "invalid consent id") + return + } + + 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 + } + + aggregatedBody, err := h.svc.BuildAggregatedConsentResponse(r, baseResp.Body) + if err != nil { + writeProxyError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(aggregatedBody) +} + +// ConsentApprove handles POST /me/consents/{consentId}/approve. +func (h *Handler) ConsentApprove(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed") + return + } + userID, ok := h.resolveUserID(w, r) + 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") + return + } + if !isValidConsentID(consentID) { + writeJSONError(w, http.StatusBadRequest, "INVALID_CONSENT_ID", "invalid consent id") + return + } + body, err := h.readBoundedBody(r) + if err != nil { + writeBodyReadError(w, err) + return + } + selections, err := parseApprovalSelections(body) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "INVALID_PAYLOAD", "invalid request payload") + return + } + 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 + } + + payload, trustedClientID, err := h.svc.BuildApprovalUpdatePayload(r, baseResp.Body, selections, userID) + if err != nil { + if errors.Is(err, proxy.ErrUpstreamTimeout) || errors.Is(err, proxy.ErrUpstreamUnavailable) || errors.Is(err, proxy.ErrUpstreamResponseTooLarge) { + writeProxyError(w, err) + return + } + writeJSONError(w, http.StatusBadRequest, "INVALID_PAYLOAD", "invalid request payload") + return + } + if err := h.svc.ForwardWithClientID(w, r, http.MethodPut, "/api/v1/consents/"+url.PathEscape(consentID), nil, payload, trustedClientID); err != nil { + writeProxyError(w, err) + } +} + +// ConsentRevoke handles PUT /me/consents/{consentId}/revoke. +func (h *Handler) ConsentRevoke(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + writeJSONError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed") + return + } + userID, ok := h.resolveUserID(w, r) + 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") + return + } + if !isValidConsentID(consentID) { + writeJSONError(w, http.StatusBadRequest, "INVALID_CONSENT_ID", "invalid consent id") + return + } + body, err := h.readBoundedBody(r) + if err != nil { + writeBodyReadError(w, err) + return + } + payload, err := buildRevokePayload(body, userID) + if err != nil { + writeJSONError(w, http.StatusBadRequest, "INVALID_PAYLOAD", "invalid request payload") + return + } + if err := h.svc.Forward(w, r, http.MethodPut, "/api/v1/consents/"+url.PathEscape(consentID)+"/revoke", nil, payload); err != nil { + writeProxyError(w, err) + } +} + +func isValidConsentID(id string) bool { + return consentIDPattern.MatchString(strings.TrimSpace(id)) +} + +func writeProxyError(w http.ResponseWriter, err error) { + if errors.Is(err, proxy.ErrUpstreamTimeout) { + writeJSONError(w, http.StatusGatewayTimeout, "UPSTREAM_TIMEOUT", "upstream timeout") + return + } + if errors.Is(err, proxy.ErrUpstreamResponseTooLarge) { + writeJSONError(w, http.StatusBadGateway, "UPSTREAM_RESPONSE_TOO_LARGE", "upstream response too large") + return + } + writeJSONError(w, http.StatusBadGateway, "UPSTREAM_UNAVAILABLE", "upstream unavailable") +} + +func writeJSONError(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}) +} + +func writeBodyReadError(w http.ResponseWriter, err error) { + if errors.Is(err, errRequestBodyTooLarge) { + writeJSONError(w, http.StatusRequestEntityTooLarge, "REQUEST_TOO_LARGE", "request entity too large") + return + } + writeJSONError(w, http.StatusBadRequest, "INVALID_REQUEST_BODY", "invalid request body") +} + +func (h *Handler) resolveUserID(w http.ResponseWriter, r *http.Request) (string, bool) { + userID, ok := systemcontext.UserIDFromContext(r.Context()) + if !ok { + writeJSONError(w, http.StatusServiceUnavailable, "PLACEHOLDER_UNAVAILABLE", "placeholder identity unavailable") + return "", false + } + return userID, true +} + +func (h *Handler) readBoundedBody(r *http.Request) ([]byte, error) { + if r.Body == nil { + return nil, nil + } + defer func() { + _ = r.Body.Close() + }() + limited := io.LimitReader(r.Body, h.cfg.MaxRequestBytes+1) + body, err := io.ReadAll(limited) + if err != nil { + return nil, err + } + if int64(len(body)) > h.cfg.MaxRequestBytes { + return nil, errRequestBodyTooLarge + } + return body, nil +} diff --git a/portal/backend/internal/me/init.go b/portal/backend/internal/me/init.go new file mode 100644 index 00000000..b85e7dcf --- /dev/null +++ b/portal/backend/internal/me/init.go @@ -0,0 +1,47 @@ +/* + * 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 me + +import ( + "net/http" + + "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 { + 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("PUT /me/consents/{consentId}/revoke", middleware.UserID(http.HandlerFunc(handler.ConsentRevoke), userIDOptions)) + + return nil +} diff --git a/portal/backend/internal/me/service.go b/portal/backend/internal/me/service.go new file mode 100644 index 00000000..ab9fb6cc --- /dev/null +++ b/portal/backend/internal/me/service.go @@ -0,0 +1,539 @@ +/* + * 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 me + +import ( + "encoding/json" + "errors" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/wso2/openfgc/portal/backend/internal/proxy" + "github.com/wso2/openfgc/portal/backend/internal/system/config" +) + +// Service contains /me endpoint logic layered on top of proxy transport. +type Service struct { + proxy *proxy.Service +} + +type consentRetrievalResponse struct { + ID string `json:"id"` + Purposes []consentPurposeItem `json:"purposes"` + CreatedTime int64 `json:"createdTime"` + UpdatedTime int64 `json:"updatedTime"` + ClientID string `json:"clientId"` + Type string `json:"type"` + Status string `json:"status"` + Frequency *int `json:"frequency,omitempty"` + ValidityTime *int64 `json:"validityTime,omitempty"` + RecurringIndicator *bool `json:"recurringIndicator,omitempty"` + DataAccessValidityDuration *int64 `json:"dataAccessValidityDuration,omitempty"` + Attributes map[string]any `json:"attributes,omitempty"` + Authorizations []consentAuthorizationEntry `json:"authorizations,omitempty"` +} + +type consentPurposeItem struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Elements []consentElementItem `json:"elements"` +} + +type consentElementItem struct { + Name string `json:"name"` + IsUserApproved bool `json:"isUserApproved"` + Value any `json:"value,omitempty"` + IsMandatory *bool `json:"isMandatory,omitempty"` + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + Properties map[string]any `json:"properties,omitempty"` +} + +type consentAuthorizationEntry struct { + ID string `json:"id"` + UserID *string `json:"userId,omitempty"` + Type string `json:"type"` + Status string `json:"status"` + UpdatedTime int64 `json:"updatedTime"` + Resources any `json:"resources,omitempty"` +} + +type consentApprovalSelection struct { + PurposeName string `json:"purposeName"` + ElementName string `json:"elementName"` +} + +type consentAuthorizationPayload struct { + UserID *string `json:"userId,omitempty"` + Type string `json:"type"` + Status string `json:"status"` + Resources any `json:"resources"` +} + +type consentUpdatePayload struct { + Type string `json:"type"` + ValidityTime *int64 `json:"validityTime,omitempty"` + RecurringIndicator *bool `json:"recurringIndicator,omitempty"` + DataAccessValidityDuration *int64 `json:"dataAccessValidityDuration,omitempty"` + Frequency *int `json:"frequency,omitempty"` + Purposes []consentPurposeItem `json:"purposes"` + Attributes map[string]any `json:"attributes,omitempty"` + Authorizations []consentAuthorizationPayload `json:"authorizations,omitempty"` +} + +type purposeListResponse struct { + Data []purposeMetadata `json:"data"` +} + +type purposeMetadata struct { + ClientID string `json:"clientId"` + Name string `json:"name"` + Description *string `json:"description"` + Elements []purposeElementEntry `json:"elements"` +} + +type purposeElementEntry struct { + Name string `json:"name"` + IsMandatory bool `json:"isMandatory"` +} + +type elementListResponse struct { + Data []elementMetadata `json:"data"` +} + +type elementMetadata struct { + Name string `json:"name"` + Type string `json:"type"` + Description *string `json:"description"` + Properties map[string]any `json:"properties"` +} + +// NewService builds a me service from app config. +func NewService(cfg config.ProxyConfig) (*Service, error) { + svc, err := proxy.NewService(cfg) + if err != nil { + return nil, err + } + return &Service{proxy: svc}, nil +} + +// Forward forwards to upstream via the shared proxy service. +func (s *Service) Forward(w http.ResponseWriter, r *http.Request, upstreamMethod, upstreamPath string, queryMutator func(url.Values), body []byte) error { + return s.proxy.Forward(w, r, upstreamMethod, upstreamPath, queryMutator, body) +} + +// ForwardWithClientID forwards to upstream with a trusted client ID. +func (s *Service) ForwardWithClientID(w http.ResponseWriter, r *http.Request, upstreamMethod, upstreamPath string, queryMutator func(url.Values), body []byte, trustedClientID string) error { + return s.proxy.ForwardWithClientID(w, r, upstreamMethod, upstreamPath, queryMutator, body, trustedClientID) +} + +// ForwardRaw forwards to upstream and returns a caller-managed response. +func (s *Service) ForwardRaw(r *http.Request, upstreamMethod, upstreamPath string, queryMutator func(url.Values), body []byte) (*proxy.UpstreamResponse, error) { + return s.proxy.ForwardRaw(r, upstreamMethod, upstreamPath, queryMutator, body) +} + +// WriteUpstreamResponse writes an upstream response back to the caller. +func (s *Service) WriteUpstreamResponse(w http.ResponseWriter, resp *proxy.UpstreamResponse) { + _ = s.proxy.WriteUpstreamResponse(w, resp) +} + +func toConsentApprovalKey(purposeName, elementName string) string { + return purposeName + "::" + elementName +} + +// BuildAggregatedConsentResponse enriches a consent response with purpose and element metadata. +func (s *Service) BuildAggregatedConsentResponse(r *http.Request, baseBody []byte) ([]byte, error) { + var consent consentRetrievalResponse + if err := json.Unmarshal(baseBody, &consent); err != nil { + return nil, proxy.ErrUpstreamUnavailable + } + + purposeMetadataByName := make(map[string]purposeMetadata, len(consent.Purposes)) + for _, purpose := range consent.Purposes { + if _, exists := purposeMetadataByName[purpose.Name]; exists { + continue + } + metadata, err := s.fetchPurposeMetadata(r, consent.ClientID, purpose) + if err != nil { + return nil, err + } + purposeMetadataByName[purpose.Name] = metadata + } + + elementMetadataByName := make(map[string]elementMetadata) + for _, purpose := range consent.Purposes { + for _, element := range purpose.Elements { + if _, exists := elementMetadataByName[element.Name]; exists { + continue + } + metadata, err := s.fetchElementMetadata(r, element.Name) + if err != nil { + return nil, err + } + elementMetadataByName[element.Name] = metadata + } + } + + for purposeIndex := range consent.Purposes { + purpose := &consent.Purposes[purposeIndex] + purposeMetadata, exists := purposeMetadataByName[purpose.Name] + if !exists { + return nil, proxy.ErrUpstreamUnavailable + } + if purposeMetadata.Description != nil { + purpose.Description = *purposeMetadata.Description + } + + mandatoryByElement := make(map[string]bool, len(purposeMetadata.Elements)) + for _, entry := range purposeMetadata.Elements { + mandatoryByElement[entry.Name] = entry.IsMandatory + } + + for elementIndex := range purpose.Elements { + element := &purpose.Elements[elementIndex] + mandatory, exists := mandatoryByElement[element.Name] + if !exists { + return nil, proxy.ErrUpstreamUnavailable + } + element.IsMandatory = &mandatory + + elementMetadata, exists := elementMetadataByName[element.Name] + if !exists { + return nil, proxy.ErrUpstreamUnavailable + } + element.Type = elementMetadata.Type + if elementMetadata.Description != nil { + element.Description = *elementMetadata.Description + } + element.Properties = elementMetadata.Properties + } + } + + aggregated, err := json.Marshal(consent) + if err != nil { + return nil, proxy.ErrUpstreamUnavailable + } + + return aggregated, nil +} + +func (s *Service) fetchPurposeMetadata(r *http.Request, clientID string, consentPurpose consentPurposeItem) (purposeMetadata, error) { + exactByClient, err := s.fetchPurposeMetadataPage(r, consentPurpose.Name, clientID) + if err != nil { + return purposeMetadata{}, err + } + elementNames := make(map[string]struct{}, len(consentPurpose.Elements)) + for _, element := range consentPurpose.Elements { + elementNames[element.Name] = struct{}{} + } + + if purpose, ok := selectPurposeCandidate(exactByClient, consentPurpose.Name, clientID, elementNames); ok { + return purpose, nil + } + + exactByName, err := s.fetchPurposeMetadataPage(r, consentPurpose.Name, "") + if err != nil { + return purposeMetadata{}, err + } + if purpose, ok := selectPurposeCandidate(exactByName, consentPurpose.Name, clientID, elementNames); ok { + return purpose, nil + } + + return purposeMetadata{}, proxy.ErrUpstreamUnavailable +} + +func (s *Service) fetchPurposeMetadataPage(r *http.Request, purposeName, clientID string) ([]purposeMetadata, error) { + resp, err := s.proxy.ForwardRaw(r, http.MethodGet, "/api/v1/consent-purposes", func(q url.Values) { + q.Set("name", purposeName) + if clientID != "" { + q.Set("clientIds", clientID) + } + q.Set("limit", "50") + q.Set("offset", "0") + }, nil) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, proxy.ErrUpstreamUnavailable + } + + var payload purposeListResponse + if err := json.Unmarshal(resp.Body, &payload); err != nil { + return nil, proxy.ErrUpstreamUnavailable + } + + return payload.Data, nil +} + +func selectPurposeCandidate(candidates []purposeMetadata, purposeName, clientID string, requiredElements map[string]struct{}) (purposeMetadata, bool) { + matchingName := make([]purposeMetadata, 0, len(candidates)) + for _, purpose := range candidates { + if purpose.Name != purposeName { + continue + } + matchingName = append(matchingName, purpose) + } + + if len(matchingName) == 0 { + return purposeMetadata{}, false + } + + best := make([]purposeMetadata, 0, len(matchingName)) + for _, purpose := range matchingName { + if purposeContainsAllElements(purpose, requiredElements) { + best = append(best, purpose) + } + } + if len(best) == 0 { + best = matchingName + } + + if clientID != "" { + for _, purpose := range best { + if purpose.ClientID == clientID { + return purpose, true + } + } + } + + return best[0], true +} + +func purposeContainsAllElements(purpose purposeMetadata, requiredElements map[string]struct{}) bool { + if len(requiredElements) == 0 { + return true + } + purposeElements := make(map[string]struct{}, len(purpose.Elements)) + for _, element := range purpose.Elements { + purposeElements[element.Name] = struct{}{} + } + for name := range requiredElements { + if _, ok := purposeElements[name]; !ok { + return false + } + } + return true +} + +func (s *Service) fetchElementMetadata(r *http.Request, elementName string) (elementMetadata, error) { + resp, err := s.proxy.ForwardRaw(r, http.MethodGet, "/api/v1/consent-elements", func(q url.Values) { + q.Set("name", elementName) + q.Set("limit", strconv.Itoa(50)) + q.Set("offset", "0") + }, nil) + if err != nil { + return elementMetadata{}, err + } + if resp.StatusCode != http.StatusOK { + return elementMetadata{}, proxy.ErrUpstreamUnavailable + } + + var payload elementListResponse + if err := json.Unmarshal(resp.Body, &payload); err != nil { + return elementMetadata{}, proxy.ErrUpstreamUnavailable + } + for _, element := range payload.Data { + if element.Name == elementName { + return element, nil + } + } + + return elementMetadata{}, proxy.ErrUpstreamUnavailable +} + +func buildRevokePayload(in []byte, userID string) ([]byte, error) { + userID = strings.TrimSpace(userID) + if userID == "" { + return nil, errors.New("missing actionBy") + } + + payload := map[string]any{} + if len(in) > 0 { + if err := json.Unmarshal(in, &payload); err != nil { + return nil, err + } + if payload == nil { + payload = map[string]any{} + } + } + payload["actionBy"] = userID + return json.Marshal(payload) +} + +func parseApprovalSelections(in []byte) ([]consentApprovalSelection, error) { + if len(in) == 0 { + return nil, nil + } + var selections []consentApprovalSelection + if err := json.Unmarshal(in, &selections); err != nil { + return nil, err + } + for _, selection := range selections { + if strings.TrimSpace(selection.PurposeName) == "" || strings.TrimSpace(selection.ElementName) == "" { + return nil, errors.New("invalid approval selection") + } + } + return selections, nil +} + +// BuildApprovalUpdatePayload builds the consent update payload for an approval action. +func (s *Service) BuildApprovalUpdatePayload(r *http.Request, baseBody []byte, selections []consentApprovalSelection, userID string) ([]byte, string, error) { + var consent consentRetrievalResponse + if err := json.Unmarshal(baseBody, &consent); err != nil { + return nil, "", proxy.ErrUpstreamUnavailable + } + + if err := s.enrichMandatoryFlags(r, &consent); err != nil { + return nil, "", err + } + + selectedOptionalElements := make(map[string]struct{}, len(selections)) + for _, selection := range selections { + selectedOptionalElements[toConsentApprovalKey(selection.PurposeName, selection.ElementName)] = struct{}{} + } + + matchedSelections := make(map[string]struct{}, len(selectedOptionalElements)) + updatedPurposes := make([]consentPurposeItem, len(consent.Purposes)) + for purposeIndex, purpose := range consent.Purposes { + updatedPurpose := purpose + updatedPurpose.Elements = make([]consentElementItem, len(purpose.Elements)) + for elementIndex, element := range purpose.Elements { + updatedElement := element + if element.IsMandatory != nil && *element.IsMandatory { + updatedElement.IsUserApproved = true + } else { + key := toConsentApprovalKey(purpose.Name, element.Name) + if _, isSelected := selectedOptionalElements[key]; isSelected { + updatedElement.IsUserApproved = true + matchedSelections[key] = struct{}{} + } + } + updatedPurpose.Elements[elementIndex] = updatedElement + } + updatedPurposes[purposeIndex] = updatedPurpose + } + + if len(matchedSelections) != len(selectedOptionalElements) { + return nil, "", errors.New("invalid approval selection") + } + + updatedAuthorizations := make([]consentAuthorizationPayload, 0, len(consent.Authorizations)) + for _, authorization := range consent.Authorizations { + updatedAuthorizations = append(updatedAuthorizations, consentAuthorizationPayload{ + UserID: authorization.UserID, + Type: authorization.Type, + Status: authorization.Status, + Resources: normalizeAuthorizationResources(authorization.Resources), + }) + } + + userID = strings.TrimSpace(userID) + if userID == "" { + return nil, "", proxy.ErrUpstreamUnavailable + } + updatedAuthorization := consentAuthorizationPayload{ + UserID: &userID, + Type: "authorisation", + Status: "APPROVED", + Resources: map[string]any{}, + } + + if index, ok := findAuthorizationIndexToUpdate(consent.Authorizations, userID); ok { + updatedAuthorizations[index] = updatedAuthorization + } else { + updatedAuthorizations = append(updatedAuthorizations, updatedAuthorization) + } + + payload := consentUpdatePayload{ + Type: consent.Type, + ValidityTime: consent.ValidityTime, + RecurringIndicator: consent.RecurringIndicator, + DataAccessValidityDuration: consent.DataAccessValidityDuration, + Frequency: consent.Frequency, + Purposes: updatedPurposes, + Attributes: consent.Attributes, + Authorizations: updatedAuthorizations, + } + + serializedPayload, err := json.Marshal(payload) + if err != nil { + return nil, "", proxy.ErrUpstreamUnavailable + } + + return serializedPayload, consent.ClientID, nil +} + +func (s *Service) enrichMandatoryFlags(r *http.Request, consent *consentRetrievalResponse) error { + purposeMetadataByName := make(map[string]purposeMetadata, len(consent.Purposes)) + for _, purpose := range consent.Purposes { + if _, exists := purposeMetadataByName[purpose.Name]; exists { + continue + } + metadata, err := s.fetchPurposeMetadata(r, consent.ClientID, purpose) + if err != nil { + return err + } + purposeMetadataByName[purpose.Name] = metadata + } + + for purposeIndex := range consent.Purposes { + purpose := &consent.Purposes[purposeIndex] + purposeMetadata, exists := purposeMetadataByName[purpose.Name] + if !exists { + return proxy.ErrUpstreamUnavailable + } + + mandatoryByElement := make(map[string]bool, len(purposeMetadata.Elements)) + for _, entry := range purposeMetadata.Elements { + mandatoryByElement[entry.Name] = entry.IsMandatory + } + + for elementIndex := range purpose.Elements { + element := &purpose.Elements[elementIndex] + mandatory, exists := mandatoryByElement[element.Name] + if !exists { + return proxy.ErrUpstreamUnavailable + } + element.IsMandatory = &mandatory + } + } + + return nil +} + +func normalizeAuthorizationResources(resources any) any { + if resources == nil { + return map[string]any{} + } + return resources +} + +func findAuthorizationIndexToUpdate(authorizations []consentAuthorizationEntry, userID string) (int, bool) { + userID = strings.TrimSpace(userID) + for index, authorization := range authorizations { + if authorization.UserID != nil && strings.EqualFold(strings.TrimSpace(*authorization.UserID), userID) { + return index, true + } + } + + return -1, false +} diff --git a/portal/backend/internal/proxy/handler.go b/portal/backend/internal/proxy/handler.go new file mode 100644 index 00000000..804fa69f --- /dev/null +++ b/portal/backend/internal/proxy/handler.go @@ -0,0 +1,124 @@ +/* + * 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 proxy + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/wso2/openfgc/portal/backend/internal/system/config" +) + +// Handler serves passthrough /api routes. +type Handler struct { + svc *Service + cfg config.ProxyConfig +} + +type errorResponse struct { + Code string `json:"code"` + Message string `json:"message"` +} + +var errRequestBodyTooLarge = errors.New("request body too large") + +// NewHandler creates a proxy handler with initialized service. +func NewHandler(cfg config.ProxyConfig) (*Handler, error) { + svc, err := NewService(cfg) + if err != nil { + return nil, err + } + return &Handler{svc: svc, cfg: cfg}, nil +} + +// API proxies passthrough /api/* routes to /api/v1/* after allowlist checks. +func (h *Handler) API(w http.ResponseWriter, r *http.Request) { + knownPath, methodAllowed := h.svc.CheckAPIAccess(r.Method, r.URL.Path) + if !knownPath { + writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "route not found") + return + } + if !h.svc.IsAllowedPassthroughMethod(r.Method) || !methodAllowed { + writeJSONError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed") + return + } + path, ok := strings.CutPrefix(r.URL.Path, "/api") + if !ok { + writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "route not found") + return + } + if path == "" { + path = "/" + } + body, err := h.readBoundedBody(r) + if err != nil { + writeBodyReadError(w, err) + return + } + if err := h.svc.Forward(w, r, r.Method, "/api/v1"+path, nil, body); err != nil { + writeProxyError(w, err) + } +} + +func writeProxyError(w http.ResponseWriter, err error) { + if errors.Is(err, ErrUpstreamTimeout) { + writeJSONError(w, http.StatusGatewayTimeout, "UPSTREAM_TIMEOUT", "upstream timeout") + return + } + if errors.Is(err, ErrUpstreamResponseTooLarge) { + writeJSONError(w, http.StatusBadGateway, "UPSTREAM_RESPONSE_TOO_LARGE", "upstream response too large") + return + } + writeJSONError(w, http.StatusBadGateway, "UPSTREAM_UNAVAILABLE", "upstream unavailable") +} + +func writeJSONError(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}) +} + +func writeBodyReadError(w http.ResponseWriter, err error) { + if errors.Is(err, errRequestBodyTooLarge) { + writeJSONError(w, http.StatusRequestEntityTooLarge, "REQUEST_TOO_LARGE", "request entity too large") + return + } + writeJSONError(w, http.StatusBadRequest, "INVALID_REQUEST_BODY", "invalid request body") +} + +func (h *Handler) readBoundedBody(r *http.Request) ([]byte, error) { + if r.Body == nil { + return nil, nil + } + defer func() { + _ = r.Body.Close() + }() + limited := io.LimitReader(r.Body, h.cfg.MaxRequestBytes+1) + body, err := io.ReadAll(limited) + if err != nil { + return nil, err + } + if int64(len(body)) > h.cfg.MaxRequestBytes { + return nil, errRequestBodyTooLarge + } + return body, nil +} diff --git a/portal/backend/internal/proxy/init.go b/portal/backend/internal/proxy/init.go new file mode 100644 index 00000000..fcb88062 --- /dev/null +++ b/portal/backend/internal/proxy/init.go @@ -0,0 +1,36 @@ +/* + * 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 proxy + +import ( + "net/http" + + "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 { + handler, err := NewHandler(cfg) + if err != nil { + return err + } + + mux.HandleFunc("/api/{path...}", handler.API) + return nil +} diff --git a/portal/backend/internal/proxy/service.go b/portal/backend/internal/proxy/service.go new file mode 100644 index 00000000..40199248 --- /dev/null +++ b/portal/backend/internal/proxy/service.go @@ -0,0 +1,386 @@ +/* + * 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 proxy contains outbound consent-server proxying logic. +package proxy + +import ( + "bytes" + "context" + "crypto/rand" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + + "github.com/wso2/openfgc/portal/backend/internal/system/config" + "github.com/wso2/openfgc/portal/backend/internal/system/correlation" +) + +var hopByHopHeaders = toCanonicalHeaderSet( + "Connection", + "Keep-Alive", + "Proxy-Authenticate", + "Proxy-Authorization", + "TE", + "Trailer", + "Transfer-Encoding", + "Upgrade", +) + +var ( + // ErrUpstreamTimeout is returned when upstream request times out. + ErrUpstreamTimeout = errors.New("upstream timeout") + // ErrUpstreamUnavailable is returned when upstream cannot be reached. + ErrUpstreamUnavailable = errors.New("upstream unavailable") + // ErrUpstreamResponseTooLarge is returned when an upstream body exceeds the configured buffer limit. + ErrUpstreamResponseTooLarge = errors.New("upstream response too large") +) + +var proxyFallbackSequence uint64 + +type routeSpec struct { + pathParts []string + methods map[string]struct{} +} + +var allowedAPIRoutes = []routeSpec{ + {pathParts: []string{"consents"}, methods: toMethodSet("GET", "POST")}, + {pathParts: []string{"consents", "attributes"}, methods: toMethodSet("GET")}, + {pathParts: []string{"consents", "validate"}, methods: toMethodSet("POST")}, + {pathParts: []string{"consents", "*"}, methods: toMethodSet("GET", "PUT")}, + {pathParts: []string{"consents", "*", "revoke"}, methods: toMethodSet("PUT")}, + {pathParts: []string{"consents", "*", "authorizations"}, methods: toMethodSet("GET", "POST")}, + {pathParts: []string{"consents", "*", "authorizations", "*"}, methods: toMethodSet("GET", "PUT")}, + {pathParts: []string{"consent-elements"}, methods: toMethodSet("GET", "POST")}, + {pathParts: []string{"consent-elements", "validate"}, methods: toMethodSet("POST")}, + {pathParts: []string{"consent-elements", "*"}, methods: toMethodSet("GET", "PUT", "DELETE")}, + {pathParts: []string{"consent-purposes"}, methods: toMethodSet("GET", "POST")}, + {pathParts: []string{"consent-purposes", "*"}, methods: toMethodSet("GET", "PUT", "DELETE")}, +} + +// Service proxies requests to consent-server with route-specific transforms. +type Service struct { + cfg config.ProxyConfig + baseURL *url.URL + http *http.Client + allowlist map[string]struct{} +} + +// UpstreamResponse captures proxied response details for caller-managed handling. +type UpstreamResponse struct { + StatusCode int + Headers http.Header + Body []byte +} + +// NewService builds a proxy service from app config. +func NewService(cfg config.ProxyConfig) (*Service, error) { + parsed, err := config.ValidateOpenFGCAPIURL(cfg.OpenFGCAPIURL) + if err != nil { + return nil, err + } + allow := make(map[string]struct{}, len(cfg.AllowedPassthrough)) + for _, m := range cfg.AllowedPassthrough { + allow[strings.ToUpper(strings.TrimSpace(m))] = struct{}{} + } + return &Service{ + cfg: cfg, + baseURL: parsed, + http: &http.Client{ + Timeout: cfg.OpenFGCAPITimeout, + }, + allowlist: allow, + }, nil +} + +// IsAllowedPassthroughMethod checks whether a passthrough /api method is allowed. +func (s *Service) IsAllowedPassthroughMethod(method string) bool { + _, ok := s.allowlist[strings.ToUpper(method)] + return ok +} + +// CheckAPIAccess returns whether the API path is known and whether method is allowed. +func (s *Service) CheckAPIAccess(method, fullPath string) (knownPath bool, methodAllowed bool) { + path, ok := strings.CutPrefix(fullPath, "/api/") + if !ok { + return false, false + } + trimmed := strings.Trim(path, "/") + if trimmed == "" { + return false, false + } + parts := strings.Split(trimmed, "/") + method = strings.ToUpper(method) + + for _, spec := range allowedAPIRoutes { + if !routeMatches(spec.pathParts, parts) { + continue + } + _, allowed := spec.methods[method] + return true, allowed + } + + return false, false +} + +// Forward sends a transformed request to upstream and writes the response. +func (s *Service) Forward(w http.ResponseWriter, r *http.Request, upstreamMethod, upstreamPath string, queryMutator func(url.Values), body []byte) error { + resp, err := s.ForwardRaw(r, upstreamMethod, upstreamPath, queryMutator, body) + if err != nil { + return err + } + + s.copyResponseHeaders(w.Header(), resp.Headers) + w.WriteHeader(resp.StatusCode) + if len(resp.Body) == 0 { + return nil + } + _, err = w.Write(resp.Body) + return err +} + +// ForwardWithClientID sends a transformed request to upstream using the provided trusted client id. +func (s *Service) ForwardWithClientID(w http.ResponseWriter, r *http.Request, upstreamMethod, upstreamPath string, queryMutator func(url.Values), body []byte, trustedClientID string) error { + resp, err := s.ForwardRawWithClientID(r, upstreamMethod, upstreamPath, queryMutator, body, trustedClientID) + if err != nil { + return err + } + + s.copyResponseHeaders(w.Header(), resp.Headers) + w.WriteHeader(resp.StatusCode) + if len(resp.Body) == 0 { + return nil + } + _, err = w.Write(resp.Body) + return err +} + +// ForwardRaw sends a transformed request to upstream and returns response status, headers, and body. +func (s *Service) ForwardRaw(r *http.Request, upstreamMethod, upstreamPath string, queryMutator func(url.Values), body []byte) (*UpstreamResponse, error) { + return s.ForwardRawWithClientID(r, upstreamMethod, upstreamPath, queryMutator, body, "") +} + +// ForwardRawWithClientID sends a transformed request to upstream using the provided trusted client id. +func (s *Service) ForwardRawWithClientID(r *http.Request, upstreamMethod, upstreamPath string, queryMutator func(url.Values), body []byte, trustedClientID string) (*UpstreamResponse, error) { + ctx, cancel := context.WithTimeout(r.Context(), s.cfg.OpenFGCAPITimeout) + defer cancel() + + target := *s.baseURL + target.Path = joinURLPaths(s.baseURL.Path, upstreamPath) + query := r.URL.Query() + if queryMutator != nil { + queryMutator(query) + } + target.RawQuery = query.Encode() + + upstreamReq, err := http.NewRequestWithContext(ctx, upstreamMethod, target.String(), bytes.NewReader(body)) + if err != nil { + return nil, err + } + + s.copyHeaders(r.Header, upstreamReq.Header) + s.setTrustedHeaders(r, upstreamReq, trustedClientID) + + resp, err := s.http.Do(upstreamReq) + if err != nil { + var netErr net.Error + if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) { + return nil, ErrUpstreamTimeout + } + return nil, ErrUpstreamUnavailable + } + defer func() { + _ = resp.Body.Close() + }() + + limitedBody := io.LimitReader(resp.Body, s.cfg.MaxResponseBytes+1) + respBody, err := io.ReadAll(limitedBody) + if err != nil { + return nil, fmt.Errorf("read upstream response body: %w", ErrUpstreamUnavailable) + } + if int64(len(respBody)) > s.cfg.MaxResponseBytes { + return nil, ErrUpstreamResponseTooLarge + } + + return &UpstreamResponse{ + StatusCode: resp.StatusCode, + Headers: resp.Header.Clone(), + Body: respBody, + }, nil +} + +// WriteUpstreamResponse copies a previously fetched upstream response to the caller. +func (s *Service) WriteUpstreamResponse(w http.ResponseWriter, resp *UpstreamResponse) error { + s.copyResponseHeaders(w.Header(), resp.Headers) + w.WriteHeader(resp.StatusCode) + if len(resp.Body) == 0 { + return nil + } + _, err := w.Write(resp.Body) + return err +} + +func joinURLPaths(basePath string, upstreamPath string) string { + basePath = strings.TrimRight(basePath, "/") + upstreamPath = strings.TrimLeft(upstreamPath, "/") + + switch { + case basePath == "" && upstreamPath == "": + return "" + case basePath == "": + return "/" + upstreamPath + case upstreamPath == "": + return basePath + default: + return basePath + "/" + upstreamPath + } +} + +func toMethodSet(methods ...string) map[string]struct{} { + set := make(map[string]struct{}, len(methods)) + for _, m := range methods { + set[strings.ToUpper(m)] = struct{}{} + } + return set +} + +func toCanonicalHeaderSet(names ...string) map[string]struct{} { + set := make(map[string]struct{}, len(names)) + for _, name := range names { + set[http.CanonicalHeaderKey(name)] = struct{}{} + } + return set +} + +func routeMatches(patternParts, parts []string) bool { + if len(patternParts) != len(parts) { + return false + } + for i := range patternParts { + if patternParts[i] == "*" { + if parts[i] == "" { + return false + } + continue + } + if patternParts[i] != parts[i] { + return false + } + } + return true +} + +func (s *Service) copyHeaders(src, dst http.Header) { + connectionHeaders := connectionHeaderNames(src) + for k, vals := range src { + if s.skipHeader(k, connectionHeaders) { + continue + } + for _, v := range vals { + dst.Add(k, v) + } + } +} + +func (s *Service) copyResponseHeaders(dst, src http.Header) { + connectionHeaders := connectionHeaderNames(src) + for k, vals := range src { + canonical := http.CanonicalHeaderKey(k) + if _, drop := hopByHopHeaders[canonical]; drop { + continue + } + if _, drop := connectionHeaders[canonical]; drop { + continue + } + for _, v := range vals { + dst.Add(k, v) + } + } +} + +func (s *Service) skipHeader(name string, connectionHeaders map[string]struct{}) bool { + canonical := http.CanonicalHeaderKey(name) + if _, drop := hopByHopHeaders[canonical]; drop { + return true + } + if _, drop := connectionHeaders[canonical]; drop { + return true + } + if strings.EqualFold(canonical, "Org-Id") || strings.EqualFold(canonical, "TPP-Client-Id") { + return true + } + if strings.EqualFold(canonical, "Cookie") || strings.EqualFold(canonical, "Authorization") { + return true + } + if isForwardingHeader(canonical) { + return true + } + if strings.EqualFold(canonical, "Content-Length") { + return true + } + return false +} + +func isForwardingHeader(name string) bool { + switch http.CanonicalHeaderKey(name) { + case "Forwarded", + "X-Forwarded-For", + "X-Forwarded-Host", + "X-Forwarded-Proto", + "X-Forwarded-Port", + "X-Real-Ip", + "X-Original-Forwarded-For": + return true + default: + return false + } +} + +func connectionHeaderNames(headers http.Header) map[string]struct{} { + names := make(map[string]struct{}) + for _, value := range headers.Values("Connection") { + for _, token := range strings.Split(value, ",") { + name := strings.TrimSpace(token) + if name == "" { + continue + } + names[http.CanonicalHeaderKey(name)] = struct{}{} + } + } + return names +} + +func (s *Service) setTrustedHeaders(incoming *http.Request, outgoing *http.Request, trustedClientID string) { + if s.cfg.PlaceholderOrgID != "" { + outgoing.Header.Set("org-id", s.cfg.PlaceholderOrgID) + } + if trustedClientID != "" { + outgoing.Header.Set("TPP-client-id", trustedClientID) + } else if s.cfg.PlaceholderClientID != "" { + outgoing.Header.Set("TPP-client-id", s.cfg.PlaceholderClientID) + } + correlationID := incoming.Header.Get("X-Correlation-ID") + if correlationID == "" { + correlationID = correlation.NewID(rand.Read, &proxyFallbackSequence) + } + outgoing.Header.Set("X-Correlation-ID", correlationID) +} diff --git a/portal/backend/internal/proxy/service_test.go b/portal/backend/internal/proxy/service_test.go new file mode 100644 index 00000000..91a89c82 --- /dev/null +++ b/portal/backend/internal/proxy/service_test.go @@ -0,0 +1,378 @@ +/* + * 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 proxy + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/wso2/openfgc/portal/backend/internal/system/config" +) + +func TestNewServiceRejectsInvalidUpstreamURL(t *testing.T) { + tests := []struct { + name string + url string + errText string + }{ + {name: "empty url", url: "", errText: "must not be empty"}, + {name: "relative url", url: "/consent-server", errText: "must use http or https scheme"}, + {name: "missing host", url: "http:///api", errText: "must include a host"}, + {name: "unsupported scheme", url: "ftp://localhost:9090", errText: "must use http or https scheme"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewService(config.ProxyConfig{ + OpenFGCAPIURL: tt.url, + OpenFGCAPITimeout: 2 * time.Second, + MaxRequestBytes: 1024, + MaxResponseBytes: 1024, + AllowedPassthrough: []string{"GET"}, + }) + if err == nil { + t.Fatal("expected constructor error for invalid URL") + } + if !strings.Contains(err.Error(), tt.errText) { + t.Fatalf("expected error to contain %q, got %v", tt.errText, err) + } + }) + } +} + +func TestCheckAPIAccess(t *testing.T) { + svc, err := NewService(config.ProxyConfig{ + OpenFGCAPIURL: "http://localhost:9090", + OpenFGCAPITimeout: 2 * time.Second, + MaxRequestBytes: 1024, + MaxResponseBytes: 1024, + AllowedPassthrough: []string{"GET", "POST", "PUT", "DELETE"}, + PlaceholderOrgID: "ORG-001", + PlaceholderClientID: "TPP-CLIENT-001", + }) + if err != nil { + t.Fatalf("failed to construct service: %v", err) + } + + tests := []struct { + name string + method string + path string + expectKnown bool + expectAllowed bool + }{ + {name: "known path and method", method: "GET", path: "/api/consents", expectKnown: true, expectAllowed: true}, + {name: "known path wrong method", method: "DELETE", path: "/api/consents", expectKnown: true, expectAllowed: false}, + {name: "known wildcard path", method: "PUT", path: "/api/consents/abc-123/revoke", expectKnown: true, expectAllowed: true}, + {name: "known wildcard wrong method", method: "POST", path: "/api/consents/abc-123/revoke", expectKnown: true, expectAllowed: false}, + {name: "unknown path", method: "GET", path: "/api/does-not-exist", expectKnown: false, expectAllowed: false}, + {name: "non api prefix", method: "GET", path: "/health", expectKnown: false, expectAllowed: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + known, allowed := svc.CheckAPIAccess(tt.method, tt.path) + if known != tt.expectKnown || allowed != tt.expectAllowed { + t.Fatalf("expected (known=%v, allowed=%v), got (known=%v, allowed=%v)", tt.expectKnown, tt.expectAllowed, known, allowed) + } + }) + } +} + +func TestIsAllowedPassthroughMethod(t *testing.T) { + svc, err := NewService(config.ProxyConfig{ + OpenFGCAPIURL: "http://localhost:9090", + OpenFGCAPITimeout: 2 * time.Second, + MaxRequestBytes: 1024, + MaxResponseBytes: 1024, + AllowedPassthrough: []string{"GET", "POST"}, + }) + if err != nil { + t.Fatalf("failed to construct service: %v", err) + } + + if !svc.IsAllowedPassthroughMethod("get") { + t.Fatal("expected lowercase get to be allowed") + } + if svc.IsAllowedPassthroughMethod("DELETE") { + t.Fatal("expected DELETE to be disallowed") + } +} + +func TestForwardRawMapsBodyReadFailureToUpstreamUnavailable(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Length", "10") + _, _ = w.Write([]byte("abc")) + })) + defer upstream.Close() + + svc, err := NewService(config.ProxyConfig{ + OpenFGCAPIURL: upstream.URL, + OpenFGCAPITimeout: 2 * time.Second, + MaxRequestBytes: 1024, + MaxResponseBytes: 1024, + AllowedPassthrough: []string{"GET"}, + }) + if err != nil { + t.Fatalf("failed to construct service: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://bff.local/api/consents", nil) + _, err = svc.ForwardRaw(req, http.MethodGet, "/api/v1/consents", nil, nil) + if !errors.Is(err, ErrUpstreamUnavailable) { + t.Fatalf("expected ErrUpstreamUnavailable, got: %v", err) + } +} + +func TestForwardRawRejectsOversizedUpstreamResponse(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("too-large")) + })) + defer upstream.Close() + + svc, err := NewService(config.ProxyConfig{ + OpenFGCAPIURL: upstream.URL, + OpenFGCAPITimeout: 2 * time.Second, + MaxRequestBytes: 1024, + MaxResponseBytes: 4, + AllowedPassthrough: []string{"GET"}, + }) + if err != nil { + t.Fatalf("failed to construct service: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://bff.local/api/consents", nil) + _, err = svc.ForwardRaw(req, http.MethodGet, "/api/v1/consents", nil, nil) + if !errors.Is(err, ErrUpstreamResponseTooLarge) { + t.Fatalf("expected ErrUpstreamResponseTooLarge, got: %v", err) + } +} + +func TestForwardRawPreservesConfiguredBasePath(t *testing.T) { + var gotPath string + var gotQuery string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + svc, err := NewService(config.ProxyConfig{ + OpenFGCAPIURL: upstream.URL + "/openfgc/", + OpenFGCAPITimeout: 2 * time.Second, + MaxRequestBytes: 1024, + MaxResponseBytes: 1024, + AllowedPassthrough: []string{"GET"}, + }) + if err != nil { + t.Fatalf("failed to construct service: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://bff.local/api/consents?limit=10", nil) + _, err = svc.ForwardRaw(req, http.MethodGet, "/api/v1/consents", func(q url.Values) { + q.Set("offset", "0") + }, nil) + if err != nil { + t.Fatalf("unexpected forward error: %v", err) + } + + if gotPath != "/openfgc/api/v1/consents" { + t.Fatalf("expected joined upstream path, got %s", gotPath) + } + if gotQuery != "limit=10&offset=0" { + t.Fatalf("expected forwarded and mutated query, got %s", gotQuery) + } +} + +func TestForwardStripsHeadersNamedByConnection(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Hop-Debug"); got != "" { + t.Fatalf("expected X-Hop-Debug to be stripped, got %q", got) + } + if got := r.Header.Get("Connection"); got != "" { + t.Fatalf("expected Connection to be stripped, got %q", got) + } + if got := r.Header.Get("TE"); got != "" { + t.Fatalf("expected TE to be stripped, got %q", got) + } + if got := r.Header.Get("X-End-To-End"); got != "request-ok" { + t.Fatalf("expected X-End-To-End to be forwarded, got %q", got) + } + + w.Header().Set("Connection", "keep-alive, X-Upstream-Hop") + w.Header().Set("Keep-Alive", "timeout=5") + w.Header().Set("TE", "trailers") + w.Header().Set("X-Upstream-Hop", "1") + w.Header().Set("X-Upstream-End", "response-ok") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + defer upstream.Close() + + svc, err := NewService(config.ProxyConfig{ + OpenFGCAPIURL: upstream.URL, + OpenFGCAPITimeout: 2 * time.Second, + MaxRequestBytes: 1024, + MaxResponseBytes: 1024, + AllowedPassthrough: []string{"GET"}, + }) + if err != nil { + t.Fatalf("failed to construct service: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://bff.local/api/consents", nil) + req.Header.Set("Connection", "keep-alive, X-Hop-Debug") + req.Header.Set("Keep-Alive", "timeout=5") + req.Header.Set("TE", "trailers") + req.Header.Set("X-Hop-Debug", "1") + req.Header.Set("X-End-To-End", "request-ok") + + rr := httptest.NewRecorder() + err = svc.Forward(rr, req, http.MethodGet, "/api/v1/consents", nil, nil) + if err != nil { + t.Fatalf("unexpected forward error: %v", err) + } + + if got := rr.Header().Get("X-Upstream-Hop"); got != "" { + t.Fatalf("expected X-Upstream-Hop to be stripped, got %q", got) + } + if got := rr.Header().Get("Connection"); got != "" { + t.Fatalf("expected Connection to be stripped from response, got %q", got) + } + if got := rr.Header().Get("TE"); got != "" { + t.Fatalf("expected TE to be stripped from response, got %q", got) + } + if got := rr.Header().Get("X-Upstream-End"); got != "response-ok" { + t.Fatalf("expected X-Upstream-End to be forwarded, got %q", got) + } +} + +func TestForwardStripsClientForwardingHeaders(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for _, name := range []string{ + "Forwarded", + "X-Forwarded-For", + "X-Forwarded-Host", + "X-Forwarded-Proto", + "X-Forwarded-Port", + "X-Real-IP", + "X-Original-Forwarded-For", + } { + if got := r.Header.Get(name); got != "" { + t.Fatalf("expected %s to be stripped, got %q", name, got) + } + } + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + svc, err := NewService(config.ProxyConfig{ + OpenFGCAPIURL: upstream.URL, + OpenFGCAPITimeout: 2 * time.Second, + MaxRequestBytes: 1024, + MaxResponseBytes: 1024, + AllowedPassthrough: []string{"GET"}, + }) + if err != nil { + t.Fatalf("failed to construct service: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://bff.local/api/consents", nil) + req.Header.Set("Forwarded", "for=203.0.113.7;proto=https") + req.Header.Set("X-Forwarded-For", "203.0.113.7") + req.Header.Set("X-Forwarded-Host", "evil.example") + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Port", "443") + req.Header.Set("X-Real-IP", "203.0.113.7") + req.Header.Set("X-Original-Forwarded-For", "203.0.113.7") + + rr := httptest.NewRecorder() + if err := svc.Forward(rr, req, http.MethodGet, "/api/v1/consents", nil, nil); err != nil { + t.Fatalf("unexpected forward error: %v", err) + } +} + +func TestForwardStripsClientCredentialHeaders(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Cookie"); got != "" { + t.Fatalf("expected Cookie to be stripped, got %q", got) + } + if got := r.Header.Get("Authorization"); got != "" { + t.Fatalf("expected Authorization to be stripped, got %q", got) + } + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + svc, err := NewService(config.ProxyConfig{ + OpenFGCAPIURL: upstream.URL, + OpenFGCAPITimeout: 2 * time.Second, + MaxRequestBytes: 1024, + MaxResponseBytes: 1024, + AllowedPassthrough: []string{"GET"}, + }) + if err != nil { + t.Fatalf("failed to construct service: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://bff.local/api/consents", nil) + req.Header.Set("Cookie", "sid=secret") + req.Header.Set("Authorization", "Bearer secret") + + rr := httptest.NewRecorder() + if err := svc.Forward(rr, req, http.MethodGet, "/api/v1/consents", nil, nil); err != nil { + t.Fatalf("unexpected forward error: %v", err) + } +} + +func TestForwardGeneratesCorrelationIDWhenMissing(t *testing.T) { + var gotCorrelationID string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotCorrelationID = r.Header.Get("X-Correlation-ID") + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + svc, err := NewService(config.ProxyConfig{ + OpenFGCAPIURL: upstream.URL, + OpenFGCAPITimeout: 2 * time.Second, + MaxRequestBytes: 1024, + MaxResponseBytes: 1024, + AllowedPassthrough: []string{"GET"}, + }) + if err != nil { + t.Fatalf("failed to construct service: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "http://bff.local/api/consents", nil) + rr := httptest.NewRecorder() + if err := svc.Forward(rr, req, http.MethodGet, "/api/v1/consents", nil, nil); err != nil { + t.Fatalf("unexpected forward error: %v", err) + } + + if gotCorrelationID == "" { + t.Fatal("expected generated correlation id") + } +} diff --git a/portal/backend/internal/system/config/config.go b/portal/backend/internal/system/config/config.go new file mode 100644 index 00000000..51d8c74b --- /dev/null +++ b/portal/backend/internal/system/config/config.go @@ -0,0 +1,330 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package config provides configuration loading and validation for the BFF service. +package config + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "strings" + "time" + + "github.com/knadh/koanf/parsers/yaml" + "github.com/knadh/koanf/providers/env" + "github.com/knadh/koanf/providers/file" + "github.com/knadh/koanf/v2" +) + +// Config is the root configuration model for the BFF service. +type Config struct { + Env string `koanf:"env"` + Server ServerConfig `koanf:"server"` + Log LogConfig `koanf:"log"` + CORS CORSConfig `koanf:"cors"` + Proxy ProxyConfig `koanf:"proxy"` +} + +// CORSConfig contains browser cross-origin policy settings for local/frontend integration. +type CORSConfig struct { + AllowedOrigins []string `koanf:"allowed_origins"` + AllowedMethods []string `koanf:"allowed_methods"` + AllowedHeaders []string `koanf:"allowed_headers"` + AllowCredentials bool `koanf:"allow_credentials"` +} + +// ServerConfig contains HTTP server runtime settings. +type ServerConfig struct { + Host string `koanf:"host"` + Port int `koanf:"port"` + ReadTimeout time.Duration `koanf:"read_timeout"` + WriteTimeout time.Duration `koanf:"write_timeout"` + IdleTimeout time.Duration `koanf:"idle_timeout"` + ShutdownTimeout time.Duration `koanf:"shutdown_timeout"` +} + +// LogConfig contains logging configuration for the BFF. +type LogConfig struct { + Level string `koanf:"level"` +} + +// ProxyConfig contains upstream proxy behavior and placeholder identity settings. +type ProxyConfig struct { + OpenFGCAPIURL string `koanf:"openfgc_api_url"` + OpenFGCAPITimeout time.Duration `koanf:"openfgc_api_timeout"` + 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"` + PlaceholderClientID string `koanf:"placeholder_client_id"` + + AllowedPassthrough []string `koanf:"allowed_passthrough_methods"` +} + +// Load initializes configuration from defaults, optional file, and environment variables. +func Load() (*Config, error) { + k := koanf.New(".") + + if err := setDefaults(k); err != nil { + return nil, fmt.Errorf("set defaults: %w", err) + } + + configPath := os.Getenv("BFF_CONFIG_FILE") + if configPath != "" { + if err := k.Load(file.Provider(configPath), yaml.Parser()); err != nil { + return nil, fmt.Errorf("load config file: %w", err) + } + } + + if err := k.Load(env.Provider("BFF_", ".", func(s string) string { + s = strings.TrimPrefix(s, "BFF_") + s = strings.ToLower(s) + s = strings.ReplaceAll(s, "__", ".") + return s + }), nil); err != nil { + return nil, fmt.Errorf("load env config: %w", err) + } + + var cfg Config + if err := k.Unmarshal("", &cfg); err != nil { + return nil, fmt.Errorf("unmarshal config: %w", err) + } + + if rawMethods := os.Getenv("BFF_PROXY__ALLOWED_PASSTHROUGH_METHODS"); rawMethods != "" { + methods, err := ParseMethods(rawMethods) + if err != nil { + return nil, fmt.Errorf("parse proxy.allowed_passthrough_methods: %w", err) + } + if len(methods) > 0 { + cfg.Proxy.AllowedPassthrough = methods + } + } + + if rawOrigins := os.Getenv("BFF_CORS__ALLOWED_ORIGINS"); rawOrigins != "" { + cfg.CORS.AllowedOrigins = ParseCSV(rawOrigins) + } + if rawMethods := os.Getenv("BFF_CORS__ALLOWED_METHODS"); rawMethods != "" { + cfg.CORS.AllowedMethods = ParseCSV(rawMethods) + } + if rawHeaders := os.Getenv("BFF_CORS__ALLOWED_HEADERS"); rawHeaders != "" { + cfg.CORS.AllowedHeaders = ParseCSV(rawHeaders) + } + + return &cfg, validate(cfg) +} + +func setDefaults(k *koanf.Koanf) error { + if err := k.Set("server.host", "0.0.0.0"); err != nil { + return err + } + if err := k.Set("server.port", 8080); err != nil { + return err + } + if err := k.Set("server.read_timeout", "15s"); err != nil { + return err + } + if err := k.Set("server.write_timeout", "15s"); err != nil { + return err + } + if err := k.Set("server.idle_timeout", "60s"); err != nil { + return err + } + if err := k.Set("server.shutdown_timeout", "10s"); err != nil { + return err + } + if err := k.Set("env", "development"); err != nil { + return err + } + if err := k.Set("log.level", "info"); err != nil { + return err + } + if err := k.Set("cors.allowed_origins", []string{}); err != nil { + return err + } + 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 { + return err + } + if err := k.Set("cors.allow_credentials", false); err != nil { + return err + } + if err := k.Set("proxy.openfgc_api_url", "http://localhost:9090"); err != nil { + return err + } + if err := k.Set("proxy.openfgc_api_timeout", "10s"); err != nil { + return err + } + if err := k.Set("proxy.max_request_bytes", int64(1048576)); err != nil { + return err + } + if err := k.Set("proxy.max_response_bytes", int64(10485760)); err != nil { + return err + } + if err := k.Set("proxy.placeholder_mode_enabled", false); err != nil { + return err + } + if err := k.Set("proxy.placeholder_user_id", ""); err != nil { + return err + } + if err := k.Set("proxy.placeholder_org_id", ""); err != nil { + return err + } + if err := k.Set("proxy.placeholder_client_id", ""); err != nil { + return err + } + if err := k.Set("proxy.allowed_passthrough_methods", []string{"GET", "POST", "PUT", "DELETE"}); err != nil { + return err + } + + return nil +} + +func validate(cfg Config) error { + if cfg.Server.Port <= 0 { + return fmt.Errorf("server.port must be a positive value") + } + if cfg.Server.ShutdownTimeout <= 0 { + return fmt.Errorf("server.shutdown_timeout must be > 0") + } + if _, err := ValidateOpenFGCAPIURL(cfg.Proxy.OpenFGCAPIURL); err != nil { + return err + } + if cfg.Proxy.OpenFGCAPITimeout <= 0 { + return fmt.Errorf("proxy.openfgc_api_timeout must be > 0") + } + if cfg.Proxy.MaxRequestBytes <= 0 { + return fmt.Errorf("proxy.max_request_bytes must be > 0") + } + if cfg.Proxy.MaxResponseBytes <= 0 { + return fmt.Errorf("proxy.max_response_bytes must be > 0") + } + for _, raw := range cfg.CORS.AllowedOrigins { + origin := strings.TrimSpace(raw) + if origin == "" { + continue + } + if cfg.CORS.AllowCredentials && origin == "*" { + return fmt.Errorf("cors.allowed_origins cannot contain wildcard when cors.allow_credentials is true") + } + u, err := url.Parse(origin) + if err != nil { + return fmt.Errorf("cors.allowed_origins contains invalid URL %q: %w", origin, err) + } + if u.Scheme != "http" && u.Scheme != "https" { + return fmt.Errorf("cors.allowed_origins contains unsupported scheme for %q", origin) + } + if u.Host == "" { + return fmt.Errorf("cors.allowed_origins contains missing host for %q", origin) + } + if u.Path != "" && u.Path != "/" { + return fmt.Errorf("cors.allowed_origins must not contain a path for %q", origin) + } + if u.RawQuery != "" { + return fmt.Errorf("cors.allowed_origins must not contain a query string for %q", origin) + } + if u.Fragment != "" { + return fmt.Errorf("cors.allowed_origins must not contain a fragment for %q", origin) + } + } + if len(cfg.CORS.AllowedMethods) == 0 { + return fmt.Errorf("cors.allowed_methods must not be empty") + } + if len(cfg.CORS.AllowedHeaders) == 0 { + return fmt.Errorf("cors.allowed_headers must not be empty") + } + if cfg.CORS.AllowCredentials { + if len(cfg.CORS.AllowedOrigins) == 0 { + return fmt.Errorf("cors.allowed_origins must not be empty when cors.allow_credentials is true") + } + } + if cfg.Proxy.PlaceholderModeEnabled && strings.EqualFold(cfg.Env, "production") { + return fmt.Errorf("proxy.placeholder_mode_enabled cannot be true in production") + } + 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.PlaceholderClientID != "" { + return fmt.Errorf("proxy.placeholder_client_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 +} + +// ValidateOpenFGCAPIURL validates and parses the configured upstream OpenFGC API URL. +func ValidateOpenFGCAPIURL(rawURL string) (*url.URL, error) { + upstream := strings.TrimSpace(rawURL) + if upstream == "" { + return nil, fmt.Errorf("proxy.openfgc_api_url must not be empty") + } + + parsed, err := url.Parse(upstream) + if err != nil { + return nil, fmt.Errorf("proxy.openfgc_api_url must be a valid URL: %w", err) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, fmt.Errorf("proxy.openfgc_api_url must use http or https scheme") + } + if parsed.Host == "" { + return nil, fmt.Errorf("proxy.openfgc_api_url must include a host") + } + + return parsed, nil +} + +// ParseMethods parses a JSON array of HTTP methods from BFF_PROXY__ALLOWED_PASSTHROUGH_METHODS. +func ParseMethods(raw string) ([]string, error) { + if raw == "" { + return nil, nil + } + var methods []string + if err := json.Unmarshal([]byte(raw), &methods); err != nil { + return nil, err + } + for i := range methods { + methods[i] = strings.ToUpper(strings.TrimSpace(methods[i])) + } + return methods, nil +} + +// ParseCSV parses comma-separated values and removes empty entries. +func ParseCSV(raw string) []string { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + v := strings.TrimSpace(p) + if v == "" { + continue + } + out = append(out, v) + } + return out +} diff --git a/portal/backend/internal/system/config/config_test.go b/portal/backend/internal/system/config/config_test.go new file mode 100644 index 00000000..c802ba83 --- /dev/null +++ b/portal/backend/internal/system/config/config_test.go @@ -0,0 +1,203 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package config + +import ( + "os" + "strings" + "testing" +) + +func TestLoadFromEnv(t *testing.T) { + t.Setenv("BFF_SERVER__PORT", "8082") + t.Setenv("BFF_LOG__LEVEL", "debug") + t.Setenv("BFF_CORS__ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:5173") + t.Setenv("BFF_PROXY__MAX_RESPONSE_BYTES", "2097152") + _ = os.Unsetenv("BFF_CONFIG_FILE") + + cfg, err := Load() + if err != nil { + t.Fatalf("expected config to load, got error: %v", err) + } + + if cfg.Server.Port != 8082 { + t.Fatalf("expected port 8082, got %d", cfg.Server.Port) + } + if cfg.Log.Level != "debug" { + t.Fatalf("expected log level debug, got %s", cfg.Log.Level) + } + if len(cfg.CORS.AllowedOrigins) != 2 { + t.Fatalf("expected 2 cors origins, got %d", len(cfg.CORS.AllowedOrigins)) + } + if cfg.CORS.AllowedOrigins[0] != "http://localhost:3000" { + t.Fatalf("unexpected first origin: %s", cfg.CORS.AllowedOrigins[0]) + } + if cfg.Proxy.MaxResponseBytes != 2097152 { + t.Fatalf("expected max response bytes 2097152, got %d", cfg.Proxy.MaxResponseBytes) + } +} + +func TestInvalidCORSOriginRejected(t *testing.T) { + tests := []struct { + name string + origin string + errText string + }{ + {name: "invalid url", origin: "http://[::1", errText: "invalid URL"}, + {name: "contains path", origin: "http://localhost:3000/some/path", errText: "must not contain a path"}, + {name: "contains query", origin: "http://localhost:3000?debug=true", errText: "must not contain a query string"}, + {name: "contains fragment", origin: "http://localhost:3000#app", errText: "must not contain a fragment"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("BFF_CORS__ALLOWED_ORIGINS", tt.origin) + + _, err := Load() + if err == nil { + t.Fatal("expected config load to fail for invalid CORS origin") + } + if !strings.Contains(err.Error(), tt.errText) { + t.Fatalf("expected error to contain %q, got %v", tt.errText, err) + } + }) + } +} + +func TestAllowCredentialsRequiresNonWildcardOrigins(t *testing.T) { + t.Setenv("BFF_CORS__ALLOW_CREDENTIALS", "true") + t.Setenv("BFF_CORS__ALLOWED_ORIGINS", "*") + + _, err := Load() + if err == nil { + t.Fatal("expected config load to fail for wildcard origins with credentials") + } + if !strings.Contains(err.Error(), "cannot contain wildcard") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAllowCredentialsEnvParsing(t *testing.T) { + t.Setenv("BFF_CORS__ALLOW_CREDENTIALS", "true") + t.Setenv("BFF_CORS__ALLOWED_ORIGINS", "http://localhost:5173") + + cfg, err := Load() + if err != nil { + t.Fatalf("expected config load success, got: %v", err) + } + if !cfg.CORS.AllowCredentials { + t.Fatal("expected cors.allow_credentials to be true") + } +} + +func TestPlaceholderModeBlockedInProduction(t *testing.T) { + t.Setenv("BFF_ENV", "production") + t.Setenv("BFF_PROXY__PLACEHOLDER_MODE_ENABLED", "true") + t.Setenv("BFF_PROXY__PLACEHOLDER_USER_ID", "user@example.com") + + _, err := Load() + if err == nil { + t.Fatal("expected error when placeholder mode is enabled in production") + } + if !strings.Contains(err.Error(), "cannot be true in production") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPlaceholderValuesRejectedWhenModeDisabled(t *testing.T) { + tests := []struct { + name string + envName string + errText string + }{ + {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: "client id", envName: "BFF_PROXY__PLACEHOLDER_CLIENT_ID", errText: "proxy.placeholder_client_id must be empty"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("BFF_PROXY__PLACEHOLDER_MODE_ENABLED", "false") + t.Setenv(tt.envName, "placeholder-value") + + _, err := Load() + if err == nil { + t.Fatal("expected error when placeholder value is set while mode is disabled") + } + if !strings.Contains(err.Error(), tt.errText) { + t.Fatalf("expected error to contain %q, got %v", tt.errText, err) + } + }) + } +} + +func TestAllowedPassthroughMethodsEnvJSON(t *testing.T) { + t.Setenv("BFF_PROXY__ALLOWED_PASSTHROUGH_METHODS", `["get", "put"]`) + + cfg, err := Load() + if err != nil { + t.Fatalf("expected config to load, got error: %v", err) + } + + if len(cfg.Proxy.AllowedPassthrough) != 2 { + t.Fatalf("expected 2 allowed methods, got %d", len(cfg.Proxy.AllowedPassthrough)) + } + if cfg.Proxy.AllowedPassthrough[0] != "GET" || cfg.Proxy.AllowedPassthrough[1] != "PUT" { + t.Fatalf("unexpected methods: %#v", cfg.Proxy.AllowedPassthrough) + } +} + +func TestOpenFGCAPIURLRequiresHTTPSchemeAndHost(t *testing.T) { + tests := []struct { + name string + url string + errText string + }{ + {name: "empty url", url: "", errText: "must not be empty"}, + {name: "relative url", url: "/consent-server", errText: "must use http or https scheme"}, + {name: "missing host", url: "http:///api", errText: "must include a host"}, + {name: "unsupported scheme", url: "ftp://localhost:9090", errText: "must use http or https scheme"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("BFF_PROXY__OPENFGC_API_URL", tt.url) + + _, err := Load() + if err == nil { + t.Fatal("expected config load error") + } + if !strings.Contains(err.Error(), tt.errText) { + t.Fatalf("expected error to contain %q, got %v", tt.errText, err) + } + }) + } +} + +func TestMaxResponseBytesMustBePositive(t *testing.T) { + t.Setenv("BFF_PROXY__MAX_RESPONSE_BYTES", "0") + + _, err := Load() + if err == nil { + t.Fatal("expected config load error") + } + if !strings.Contains(err.Error(), "proxy.max_response_bytes must be > 0") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/portal/backend/internal/system/context/user.go b/portal/backend/internal/system/context/user.go new file mode 100644 index 00000000..89a23f7d --- /dev/null +++ b/portal/backend/internal/system/context/user.go @@ -0,0 +1,50 @@ +/* + * 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 provides request-scoped values shared across modules. +package context + +import ( + "context" + "strings" +) + +type userIDKey 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) +} + +// UserIDFromContext returns the effective user ID previously resolved by middleware. +func UserIDFromContext(ctx context.Context) (string, bool) { + if ctx == nil { + return "", false + } + + value, ok := ctx.Value(userIDKey{}).(string) + if !ok { + return "", false + } + value = strings.TrimSpace(value) + if value == "" { + return "", false + } + + return value, true +} diff --git a/portal/backend/internal/system/correlation/id.go b/portal/backend/internal/system/correlation/id.go new file mode 100644 index 00000000..e8ca1ff0 --- /dev/null +++ b/portal/backend/internal/system/correlation/id.go @@ -0,0 +1,47 @@ +/* + * 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 correlation provides shared correlation ID generation helpers. +package correlation + +import ( + "encoding/hex" + "os" + "strconv" + "sync/atomic" + "time" +) + +// NewID returns a correlation ID using the provided entropy source and +// sequence counter for fallback generation. +func NewID(read func([]byte) (int, error), sequence *uint64) string { + buf := make([]byte, 16) + if _, err := read(buf); err != nil { + return fallbackID(sequence) + } + return hex.EncodeToString(buf) +} + +func fallbackID(sequence *uint64) string { + next := atomic.AddUint64(sequence, 1) + timestamp := strconv.FormatInt(time.Now().UTC().UnixNano(), 36) + pid := strconv.Itoa(os.Getpid()) + seq := strconv.FormatUint(next, 36) + + return "fb-" + timestamp + "-" + pid + "-" + seq +} diff --git a/portal/backend/internal/system/healthcheck/handler.go b/portal/backend/internal/system/healthcheck/handler.go new file mode 100644 index 00000000..6f7f0b9f --- /dev/null +++ b/portal/backend/internal/system/healthcheck/handler.go @@ -0,0 +1,53 @@ +/* + * 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 healthcheck provides readiness and liveness HTTP handlers. +package healthcheck + +import ( + "encoding/json" + "net/http" +) + +// Handler serves liveness and readiness endpoints. +type Handler struct{} + +type response struct { + Status string `json:"status"` +} + +// NewHandler returns a health handler instance. +func NewHandler() *Handler { + return &Handler{} +} + +// Liveness responds with service liveness state. +func (h *Handler) Liveness(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, response{Status: "ok"}) +} + +// Readiness responds with service readiness state. +func (h *Handler) Readiness(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, response{Status: "ready"}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/portal/backend/internal/system/log/log.go b/portal/backend/internal/system/log/log.go new file mode 100644 index 00000000..25f3bfe8 --- /dev/null +++ b/portal/backend/internal/system/log/log.go @@ -0,0 +1,44 @@ +/* + * 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 log provides logger construction for the BFF service. +package log + +import ( + "log/slog" + "os" + "strings" +) + +// New returns a configured JSON logger using the provided level string. +func New(level string) *slog.Logger { + var lvl slog.Level + switch strings.ToLower(level) { + case "debug": + lvl = slog.LevelDebug + case "warn": + lvl = slog.LevelWarn + case "error": + lvl = slog.LevelError + default: + lvl = slog.LevelInfo + } + + handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}) + return slog.New(handler) +} diff --git a/portal/backend/internal/system/log/log_test.go b/portal/backend/internal/system/log/log_test.go new file mode 100644 index 00000000..30318a17 --- /dev/null +++ b/portal/backend/internal/system/log/log_test.go @@ -0,0 +1,135 @@ +/* + * 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 log + +import ( + "context" + "log/slog" + "testing" +) + +func TestNewLevelMapping(t *testing.T) { + tests := []struct { + name string + level string + debug bool + info bool + warn bool + error bool + }{ + { + name: "debug enables all levels", + level: "debug", + debug: true, + info: true, + warn: true, + error: true, + }, + { + name: "info enables info and above", + level: "info", + debug: false, + info: true, + warn: true, + error: true, + }, + { + name: "warn enables warn and error", + level: "warn", + debug: false, + info: false, + warn: true, + error: true, + }, + { + name: "error enables only error", + level: "error", + debug: false, + info: false, + warn: false, + error: true, + }, + { + name: "empty defaults to info", + level: "", + debug: false, + info: true, + warn: true, + error: true, + }, + { + name: "invalid defaults to info", + level: "invalid", + debug: false, + info: true, + warn: true, + error: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + log := New(tt.level) + if log == nil { + t.Fatal("expected non-nil logger") + } + + assertEnabled(t, log, slog.LevelDebug, tt.debug) + assertEnabled(t, log, slog.LevelInfo, tt.info) + assertEnabled(t, log, slog.LevelWarn, tt.warn) + assertEnabled(t, log, slog.LevelError, tt.error) + }) + } +} + +func TestNewLevelCaseInsensitive(t *testing.T) { + tests := []struct { + name string + level string + check slog.Level + want bool + }{ + {name: "uppercase debug", level: "DEBUG", check: slog.LevelDebug, want: true}, + {name: "mixed warn", level: "WaRn", check: slog.LevelInfo, want: false}, + {name: "uppercase error", level: "ERROR", check: slog.LevelWarn, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + log := New(tt.level) + if log == nil { + t.Fatal("expected non-nil logger") + } + + got := log.Enabled(context.Background(), tt.check) + if got != tt.want { + t.Fatalf("expected enabled(%v)=%v, got %v", tt.check, tt.want, got) + } + }) + } +} + +func assertEnabled(t *testing.T, log *slog.Logger, level slog.Level, want bool) { + t.Helper() + + got := log.Enabled(context.Background(), level) + if got != want { + t.Fatalf("expected enabled(%v)=%v, got %v", level, want, got) + } +} diff --git a/portal/backend/internal/system/middleware/correlationid.go b/portal/backend/internal/system/middleware/correlationid.go new file mode 100644 index 00000000..8bf6e90e --- /dev/null +++ b/portal/backend/internal/system/middleware/correlationid.go @@ -0,0 +1,76 @@ +/* + * 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 ( + "crypto/rand" + "log/slog" + "net/http" + + "github.com/wso2/openfgc/portal/backend/internal/system/correlation" +) + +const correlationHeader = "X-Correlation-ID" +const maxCorrelationIDLength = 64 + +var randomRead = rand.Read +var fallbackSequence uint64 + +// CorrelationID ensures each request has a correlation ID and mirrors it in responses. +func CorrelationID(log *slog.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get(correlationHeader) + if !isValidCorrelationID(id) { + id = newCorrelationID() + } + r.Header.Set(correlationHeader, id) + w.Header().Set(correlationHeader, id) + + log.Debug("request received", "method", r.Method, "path", r.URL.Path, "correlation_id", id) + next.ServeHTTP(w, r) + }) +} + +func isValidCorrelationID(id string) bool { + if id == "" || len(id) > maxCorrelationIDLength { + return false + } + + for _, r := range id { + if (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') { + continue + } + + switch r { + case '-', '_', '.', ':': + continue + default: + return false + } + } + + return true +} + +func newCorrelationID() string { + return correlation.NewID(randomRead, &fallbackSequence) +} diff --git a/portal/backend/internal/system/middleware/correlationid_test.go b/portal/backend/internal/system/middleware/correlationid_test.go new file mode 100644 index 00000000..1fc473de --- /dev/null +++ b/portal/backend/internal/system/middleware/correlationid_test.go @@ -0,0 +1,137 @@ +/* + * 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 ( + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" +) + +func TestIsValidCorrelationID_RejectsNonASCIICharacters(t *testing.T) { + if isValidCorrelationID("request-123") { + t.Fatal("expected full-width digits to be rejected") + } + if isValidCorrelationID("request-αβγ") { + t.Fatal("expected non-ASCII letters to be rejected") + } +} + +func TestNewCorrelationID_UsesUniqueFallbackWhenRandomFails(t *testing.T) { + originalRandomRead := randomRead + randomRead = func(_ []byte) (int, error) { + return 0, errors.New("entropy unavailable") + } + t.Cleanup(func() { + randomRead = originalRandomRead + }) + + first := newCorrelationID() + second := newCorrelationID() + + if first == second { + t.Fatalf("expected unique fallback ids, got same value %q", first) + } + if !isValidCorrelationID(first) { + t.Fatalf("expected first fallback id to be valid, got %q", first) + } + if !isValidCorrelationID(second) { + t.Fatalf("expected second fallback id to be valid, got %q", second) + } +} + +func TestCorrelationIDMiddleware_UsesValidClientID(t *testing.T) { + const clientID = "client-req.123:abc_DEF" + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Correlation-ID"); got != clientID { + t.Fatalf("expected request correlation id %q, got %q", clientID, got) + } + w.WriteHeader(http.StatusOK) + }) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := CorrelationID(logger, next) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + req.Header.Set("X-Correlation-ID", clientID) + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + + if got := res.Header().Get("X-Correlation-ID"); got != clientID { + t.Fatalf("expected response correlation id %q, got %q", clientID, got) + } +} + +func TestCorrelationIDMiddleware_RegeneratesInvalidClientID(t *testing.T) { + invalidID := "bad id with spaces" + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got := r.Header.Get("X-Correlation-ID") + if got == "" { + t.Fatal("expected generated request correlation id") + } + if got == invalidID { + t.Fatal("expected invalid client id to be replaced") + } + w.WriteHeader(http.StatusOK) + }) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := CorrelationID(logger, next) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + req.Header.Set("X-Correlation-ID", invalidID) + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + + got := res.Header().Get("X-Correlation-ID") + if got == "" { + t.Fatal("expected response correlation id") + } + if got == invalidID { + t.Fatal("expected invalid client id to be replaced in response") + } +} + +func TestCorrelationIDMiddleware_GeneratesWhenMissing(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Correlation-ID"); got == "" { + t.Fatal("expected generated request correlation id") + } + w.WriteHeader(http.StatusOK) + }) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := CorrelationID(logger, next) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + + if got := res.Header().Get("X-Correlation-ID"); got == "" { + t.Fatal("expected generated response correlation id") + } +} diff --git a/portal/backend/internal/system/middleware/cors.go b/portal/backend/internal/system/middleware/cors.go new file mode 100644 index 00000000..87e6dc16 --- /dev/null +++ b/portal/backend/internal/system/middleware/cors.go @@ -0,0 +1,155 @@ +/* + * 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 ( + "net" + "net/http" + "net/url" + "strings" +) + +// CORSOptions defines configurable CORS policy for browser clients. +type CORSOptions struct { + AllowedOrigins []string + AllowedMethods []string + AllowedHeaders []string + AllowCredentials bool +} + +// CORS applies origin checks and preflight handling for allowed browser origins. +func CORS(next http.Handler, options CORSOptions) http.Handler { + allowedOrigins := toSet(options.AllowedOrigins) + allowMethods := strings.Join(options.AllowedMethods, ", ") + allowHeaders := strings.Join(options.AllowedHeaders, ", ") + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := strings.TrimSpace(r.Header.Get("Origin")) + if origin == "" { + next.ServeHTTP(w, r) + return + } + + if isSameOrigin(origin, r) { + next.ServeHTTP(w, r) + return + } + + if _, ok := allowedOrigins[origin]; !ok { + w.WriteHeader(http.StatusForbidden) + return + } + + appendVary(w.Header(), "Origin") + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Methods", allowMethods) + w.Header().Set("Access-Control-Allow-Headers", allowHeaders) + if options.AllowCredentials { + w.Header().Set("Access-Control-Allow-Credentials", "true") + } + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} + +func isSameOrigin(origin string, r *http.Request) bool { + parsedOrigin, err := url.Parse(origin) + if err != nil || parsedOrigin.Scheme == "" || parsedOrigin.Host == "" { + return false + } + + requestScheme := "http" + if r.TLS != nil { + requestScheme = "https" + } + if !strings.EqualFold(parsedOrigin.Scheme, requestScheme) { + return false + } + + originHost, originPort := splitHostPort(parsedOrigin.Host) + requestHost, requestPort := splitHostPort(r.Host) + if !strings.EqualFold(originHost, requestHost) { + return false + } + + if originPort == "" { + originPort = defaultPort(requestScheme) + } + if requestPort == "" { + requestPort = defaultPort(requestScheme) + } + return originPort == requestPort +} + +func splitHostPort(hostPort string) (string, string) { + host := strings.TrimSpace(hostPort) + if host == "" { + return "", "" + } + + if parsedHost := (&url.URL{Host: host}).Hostname(); parsedHost != "" { + return parsedHost, (&url.URL{Host: host}).Port() + } + + hostOnly, port, err := net.SplitHostPort(host) + if err == nil { + return hostOnly, port + } + return host, "" +} + +func defaultPort(scheme string) string { + switch strings.ToLower(scheme) { + case "http": + return "80" + case "https": + return "443" + default: + return "" + } +} + +func toSet(values []string) map[string]struct{} { + out := make(map[string]struct{}, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + continue + } + out[trimmed] = struct{}{} + } + return out +} + +func appendVary(header http.Header, value string) { + for _, existing := range header.Values("Vary") { + for _, part := range strings.Split(existing, ",") { + if strings.EqualFold(strings.TrimSpace(part), value) { + return + } + } + } + header.Add("Vary", value) +} diff --git a/portal/backend/internal/system/middleware/cors_test.go b/portal/backend/internal/system/middleware/cors_test.go new file mode 100644 index 00000000..83e2e4dc --- /dev/null +++ b/portal/backend/internal/system/middleware/cors_test.go @@ -0,0 +1,183 @@ +/* + * 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 ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestCORSMiddleware_AllowsConfiguredOrigin(t *testing.T) { + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + }) + + handler := CORS(next, CORSOptions{ + AllowedOrigins: []string{"http://localhost:3000"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Content-Type", "X-Correlation-ID"}, + AllowCredentials: true, + }) + + req := httptest.NewRequest(http.MethodGet, "/me/consents", nil) + req.Header.Set("Origin", "http://localhost:3000") + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + + if !nextCalled { + t.Fatal("expected next handler to be called for allowed origin") + } + if res.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", res.Code) + } + if got := res.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:3000" { + t.Fatalf("expected Access-Control-Allow-Origin to be set, got %q", got) + } + if got := res.Header().Get("Access-Control-Allow-Credentials"); got != "true" { + t.Fatalf("expected Access-Control-Allow-Credentials=true, got %q", got) + } +} + +func TestCORSMiddleware_BlocksUnknownOrigin(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + handler := CORS(next, CORSOptions{ + AllowedOrigins: []string{"http://localhost:3000"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Content-Type", "X-Correlation-ID"}, + }) + + req := httptest.NewRequest(http.MethodGet, "/me/consents", nil) + req.Header.Set("Origin", "http://malicious.example") + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + + if res.Code != http.StatusForbidden { + t.Fatalf("expected status 403, got %d", res.Code) + } +} + +func TestCORSMiddleware_AllowsSameOriginWithoutAllowlist(t *testing.T) { + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + }) + + handler := CORS(next, CORSOptions{ + AllowedOrigins: []string{}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Content-Type", "X-Correlation-ID"}, + }) + + req := httptest.NewRequest(http.MethodGet, "http://bff.example.com/me/consents", nil) + req.Header.Set("Origin", "http://bff.example.com") + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + + if !nextCalled { + t.Fatal("expected next handler to be called for same-origin request") + } + if res.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", res.Code) + } + if got := res.Header().Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("expected no CORS allow-origin header for same-origin request, got %q", got) + } +} + +func TestCORSMiddleware_HandlesPreflight(t *testing.T) { + nextCalled := false + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + nextCalled = true + w.WriteHeader(http.StatusOK) + }) + + handler := CORS(next, CORSOptions{ + AllowedOrigins: []string{"http://localhost:3000"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Content-Type", "X-Correlation-ID"}, + AllowCredentials: true, + }) + + req := httptest.NewRequest(http.MethodOptions, "/me/consents", nil) + req.Header.Set("Origin", "http://localhost:3000") + req.Header.Set("Access-Control-Request-Method", "GET") + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + + if nextCalled { + t.Fatal("expected next handler not to be called for preflight") + } + if res.Code != http.StatusNoContent { + t.Fatalf("expected status 204, got %d", res.Code) + } + if got := res.Header().Get("Access-Control-Allow-Credentials"); got != "true" { + t.Fatalf("expected Access-Control-Allow-Credentials=true, got %q", got) + } +} + +func TestCORSMiddleware_AppendsVaryOrigin(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + corsHandler := CORS(next, CORSOptions{ + AllowedOrigins: []string{"http://localhost:3000"}, + AllowedMethods: []string{"GET"}, + AllowedHeaders: []string{"Content-Type"}, + }) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Vary", "Accept-Encoding") + corsHandler.ServeHTTP(w, r) + }) + + req := httptest.NewRequest(http.MethodGet, "/me/consents", nil) + req.Header.Set("Origin", "http://localhost:3000") + res := httptest.NewRecorder() + + handler.ServeHTTP(res, req) + + vary := strings.Join(res.Header().Values("Vary"), ",") + if !headerListContains(vary, "Accept-Encoding") { + t.Fatalf("expected Vary to retain Accept-Encoding, got %q", vary) + } + if !headerListContains(vary, "Origin") { + t.Fatalf("expected Vary to include Origin, got %q", vary) + } +} + +func headerListContains(headerValue, expected string) bool { + for _, part := range strings.Split(headerValue, ",") { + if strings.EqualFold(strings.TrimSpace(part), expected) { + return true + } + } + return false +} diff --git a/portal/backend/internal/system/middleware/userid.go b/portal/backend/internal/system/middleware/userid.go new file mode 100644 index 00000000..f3129159 --- /dev/null +++ b/portal/backend/internal/system/middleware/userid.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 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 new file mode 100644 index 00000000..035c1313 --- /dev/null +++ b/portal/backend/internal/system/middleware/userid_test.go @@ -0,0 +1,178 @@ +/* + * 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/bff.yaml new file mode 100644 index 00000000..9ab105be --- /dev/null +++ b/portal/backend/openapi/bff.yaml @@ -0,0 +1,370 @@ +openapi: 3.0.3 +info: + title: OpenFGC Portal BFF API + version: 0.2.0 + description: >- + Stateless portal BFF endpoints, including health checks, portal-facing + user routes, and explicit passthrough routes to consent-server. For proxied + endpoints in this spec, response payload shape is inherited from the + consent-management API. +servers: + - url: http://localhost:8080 +tags: + - name: Health + - name: User + - name: Passthrough +paths: + /health: + get: + tags: [Health] + summary: Liveness check + responses: + '200': + description: Service healthy + /health/liveness: + get: + tags: [Health] + summary: Liveness check + responses: + '200': + description: Service healthy + /health/readiness: + get: + tags: [Health] + summary: Readiness check + responses: + '200': + description: Service ready + + /me/consents: + get: + tags: [User] + summary: List current user consents + description: >- + Proxies to consent-server GET /api/v1/consents and always overwrites + userIds with trusted identity context. Response payload shape is + inherited from consent-management API. + parameters: + - in: query + name: limit + schema: + type: integer + - in: query + name: offset + schema: + type: integer + responses: + '200': + description: Upstream response + content: + application/json: + schema: + type: object + '502': + $ref: '#/components/responses/ProxyError' + '503': + $ref: '#/components/responses/ProxyError' + + /me/consents/{consentId}: + get: + tags: [User] + summary: Get consent by id for current user flow + description: >- + Aggregates consent details from consent-server by combining + GET /api/v1/consents/{consentId} with consent purpose and consent + element metadata lookups. Response includes purpose descriptions and + enriched element fields such as isMandatory, type, description, and + properties. + parameters: + - in: path + name: consentId + required: true + schema: + type: string + responses: + '200': + description: Aggregated consent detail response + content: + application/json: + schema: + $ref: '#/components/schemas/MeConsentDetailAggregatedResponse' + '404': + $ref: '#/components/responses/ProxyError' + '502': + $ref: '#/components/responses/ProxyError' + '503': + $ref: '#/components/responses/ProxyError' + + /me/consents/{consentId}/approve: + post: + tags: [User] + summary: Approve consent for current user flow + description: >- + Accepts the selected optional purpose/element approvals, fetches the + current consent, builds a full consent update payload, and forwards it + as PUT /api/v1/consents/{consentId}. The upstream TPP-client-id header + is derived from the fetched consent's clientId, mandatory elements are + always set to approved, and an existing authorization is updated to + APPROVED for the trusted current user identity (falls back to creating + one if none exist). + parameters: + - in: path + name: consentId + required: true + schema: + type: string + requestBody: + required: false + content: + application/json: + schema: + type: array + items: + type: object + required: [purposeName, elementName] + properties: + purposeName: + type: string + elementName: + type: string + responses: + '200': + description: Upstream response + content: + application/json: + schema: + type: object + '400': + $ref: '#/components/responses/ProxyError' + '404': + $ref: '#/components/responses/ProxyError' + '413': + $ref: '#/components/responses/ProxyError' + '502': + $ref: '#/components/responses/ProxyError' + '503': + $ref: '#/components/responses/ProxyError' + + /me/consents/{consentId}/revoke: + put: + tags: [User] + summary: Revoke consent for current user flow + description: >- + Response payload shape is inherited from consent-management API. + parameters: + - in: path + name: consentId + required: true + schema: + type: string + requestBody: + required: false + content: + application/json: + schema: + type: object + responses: + '200': + description: Upstream response + content: + application/json: + schema: + type: object + '400': + $ref: '#/components/responses/ProxyError' + '404': + $ref: '#/components/responses/ProxyError' + '413': + $ref: '#/components/responses/ProxyError' + '502': + $ref: '#/components/responses/ProxyError' + '503': + $ref: '#/components/responses/ProxyError' + + /api/consents: + get: + tags: [Passthrough] + summary: Passthrough search consents + description: >- + Response payload shape is inherited from consent-management API. + responses: + '200': + description: Upstream response + content: + application/json: + schema: + type: object + '404': + $ref: '#/components/responses/ProxyError' + '405': + $ref: '#/components/responses/ProxyError' + '502': + $ref: '#/components/responses/ProxyError' + '503': + $ref: '#/components/responses/ProxyError' + post: + tags: [Passthrough] + summary: Passthrough create consent + description: >- + Response payload shape is inherited from consent-management API. + requestBody: + required: false + content: + application/json: + schema: + type: object + responses: + '200': + description: Upstream response + content: + application/json: + schema: + type: object + '404': + $ref: '#/components/responses/ProxyError' + '405': + $ref: '#/components/responses/ProxyError' + '413': + $ref: '#/components/responses/ProxyError' + '502': + $ref: '#/components/responses/ProxyError' + '503': + $ref: '#/components/responses/ProxyError' + +components: + schemas: + MeConsentDetailAggregatedResponse: + type: object + required: + - id + - clientId + - type + - status + - createdTime + - updatedTime + - purposes + properties: + id: + type: string + clientId: + type: string + type: + type: string + status: + type: string + createdTime: + type: integer + format: int64 + updatedTime: + type: integer + format: int64 + frequency: + type: integer + validityTime: + type: integer + format: int64 + recurringIndicator: + type: boolean + dataAccessValidityDuration: + type: integer + format: int64 + attributes: + type: object + additionalProperties: + type: string + authorizations: + type: array + items: + $ref: '#/components/schemas/ConsentAuthorizationItem' + purposes: + type: array + items: + $ref: '#/components/schemas/MeConsentPurposeItem' + + MeConsentPurposeItem: + type: object + required: + - name + - description + - elements + properties: + name: + type: string + description: + type: string + elements: + type: array + items: + $ref: '#/components/schemas/MeConsentElementItem' + + MeConsentElementItem: + type: object + required: + - name + - isUserApproved + - isMandatory + properties: + name: + type: string + isUserApproved: + type: boolean + isMandatory: + type: boolean + type: + type: string + description: + type: string + properties: + type: object + additionalProperties: + type: string + value: + oneOf: + - type: string + - type: object + - type: array + items: {} + nullable: true + + ConsentAuthorizationItem: + type: object + required: + - id + - type + - status + - updatedTime + properties: + id: + type: string + userId: + type: string + type: + type: string + status: + type: string + updatedTime: + type: integer + format: int64 + resources: + type: object + nullable: true + + ProxyError: + type: object + required: + - code + - message + properties: + code: + type: string + example: UPSTREAM_UNAVAILABLE + message: + type: string + example: upstream unavailable + responses: + ProxyError: + description: Proxy-layer error response + content: + application/json: + schema: + $ref: '#/components/schemas/ProxyError' diff --git a/portal/backend/tests/integration/health_test.go b/portal/backend/tests/integration/health_test.go new file mode 100644 index 00000000..dfbcfc76 --- /dev/null +++ b/portal/backend/tests/integration/health_test.go @@ -0,0 +1,54 @@ +/* + * 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 ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/wso2/openfgc/portal/backend/internal/system/config" +) + +func TestLivenessEndpoint(t *testing.T) { + cfg, err := config.Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + h, err := newIntegrationHandler(*cfg) + if err != nil { + t.Fatalf("failed to create handler: %v", err) + } + ts := httptest.NewServer(h) + defer ts.Close() + + resp, err := http.Get(ts.URL + "/health/liveness") + if err != nil { + t.Fatalf("unexpected request error: %v", err) + } + defer func() { + if err := resp.Body.Close(); err != nil { + t.Fatalf("failed to close response body: %v", err) + } + }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } +} diff --git a/portal/backend/tests/integration/proxy_invalid_consent_id_test.go b/portal/backend/tests/integration/proxy_invalid_consent_id_test.go new file mode 100644 index 00000000..9627f859 --- /dev/null +++ b/portal/backend/tests/integration/proxy_invalid_consent_id_test.go @@ -0,0 +1,93 @@ +/* + * 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 := newPhase2Server(t, upstream.URL) + 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.MethodPut, 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_phase2_test.go b/portal/backend/tests/integration/proxy_phase2_test.go new file mode 100644 index 00000000..0019e33b --- /dev/null +++ b/portal/backend/tests/integration/proxy_phase2_test.go @@ -0,0 +1,1099 @@ +/* + * 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 ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/wso2/openfgc/portal/backend/internal/system/config" +) + +func newPhase2Server(t *testing.T, upstreamURL string) *httptest.Server { + t.Helper() + cfg, err := config.Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + cfg.Proxy.OpenFGCAPIURL = upstreamURL + cfg.Proxy.PlaceholderModeEnabled = true + cfg.Proxy.PlaceholderUserID = "user@example.com" + cfg.Proxy.PlaceholderOrgID = "ORG-001" + cfg.Proxy.PlaceholderClientID = "TPP-CLIENT-001" + + h, err := newIntegrationHandler(*cfg) + if err != nil { + t.Fatalf("failed to create handler: %v", err) + } + return httptest.NewServer(h) +} + +func newPhase2ServerWithTimeout(t *testing.T, upstreamURL string, timeout time.Duration) *httptest.Server { + t.Helper() + cfg, err := config.Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + cfg.Proxy.OpenFGCAPIURL = upstreamURL + cfg.Proxy.OpenFGCAPITimeout = timeout + cfg.Proxy.PlaceholderModeEnabled = true + cfg.Proxy.PlaceholderUserID = "user@example.com" + cfg.Proxy.PlaceholderOrgID = "ORG-001" + cfg.Proxy.PlaceholderClientID = "TPP-CLIENT-001" + + h, err := newIntegrationHandler(*cfg) + if err != nil { + t.Fatalf("failed to create handler: %v", err) + } + return httptest.NewServer(h) +} + +func newPhase2ServerWithMaxBytes(t *testing.T, upstreamURL string, maxBytes int64) *httptest.Server { + t.Helper() + cfg, err := config.Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + cfg.Proxy.OpenFGCAPIURL = upstreamURL + cfg.Proxy.MaxRequestBytes = maxBytes + cfg.Proxy.PlaceholderModeEnabled = true + cfg.Proxy.PlaceholderUserID = "user@example.com" + cfg.Proxy.PlaceholderOrgID = "ORG-001" + cfg.Proxy.PlaceholderClientID = "TPP-CLIENT-001" + + h, err := newIntegrationHandler(*cfg) + if err != nil { + t.Fatalf("failed to create handler: %v", err) + } + return httptest.NewServer(h) +} + +func newPhase2ServerPlaceholderDisabled(t *testing.T, upstreamURL string) *httptest.Server { + t.Helper() + cfg, err := config.Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + cfg.Proxy.OpenFGCAPIURL = upstreamURL + cfg.Proxy.PlaceholderModeEnabled = false + cfg.Proxy.PlaceholderUserID = "" + cfg.Proxy.PlaceholderOrgID = "ORG-001" + cfg.Proxy.PlaceholderClientID = "TPP-CLIENT-001" + + h, err := newIntegrationHandler(*cfg) + if err != nil { + t.Fatalf("failed to create handler: %v", err) + } + return httptest.NewServer(h) +} + +type failingReadCloser struct{} + +func (failingReadCloser) Read(_ []byte) (int, error) { + return 0, io.ErrUnexpectedEOF +} + +func (failingReadCloser) Close() error { + return nil +} + +func TestAPIPassthroughRewriteAndHeaderSafety(t *testing.T) { + var gotPath string + var gotQuery string + var gotOrg string + var gotTPP string + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + gotOrg = r.Header.Get("org-id") + gotTPP = r.Header.Get("TPP-client-id") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer upstream.Close() + + bff := newPhase2Server(t, upstream.URL) + defer bff.Close() + + req, err := http.NewRequest(http.MethodGet, bff.URL+"/api/consents?limit=10&offset=2", nil) + if err != nil { + t.Fatalf("request creation failed: %v", err) + } + req.Header.Set("org-id", "MALICIOUS") + req.Header.Set("TPP-client-id", "MALICIOUS") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if gotPath != "/api/v1/consents" { + t.Fatalf("expected rewritten path /api/v1/consents, got %s", gotPath) + } + if gotQuery != "limit=10&offset=2" { + t.Fatalf("expected preserved query limit=10&offset=2, got %s", gotQuery) + } + if gotOrg != "ORG-001" { + t.Fatalf("expected trusted org-id header, got %s", gotOrg) + } + if gotTPP != "TPP-CLIENT-001" { + t.Fatalf("expected trusted TPP-client-id header, got %s", gotTPP) + } +} + +func TestMeConsentsForcesUserIDs(t *testing.T) { + var gotPath string + var gotQuery string + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + bff := newPhase2Server(t, upstream.URL) + defer bff.Close() + + resp, err := http.Get(bff.URL + "/me/consents?userIds=attacker&limit=5") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if gotPath != "/api/v1/consents" { + t.Fatalf("expected path /api/v1/consents, got %s", gotPath) + } + queryValues, err := url.ParseQuery(gotQuery) + if err != nil { + t.Fatalf("expected valid query string, got parse error: %v", err) + } + if queryValues.Get("limit") != "5" { + t.Fatalf("expected limit=5, got %v", queryValues["limit"]) + } + userIDs := queryValues["userIds"] + if len(userIDs) != 1 || userIDs[0] != "user@example.com" { + t.Fatalf("expected forced userIds=user@example.com, got %v", userIDs) + } +} + +func TestMeConsentByIDStripsHopByHopHeadersFromUpstreamError(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Connection", "keep-alive, X-Upstream-Hop") + w.Header().Set("Keep-Alive", "timeout=5") + w.Header().Set("TE", "trailers") + w.Header().Set("Upgrade", "websocket") + w.Header().Set("X-Upstream-Hop", "1") + w.Header().Set("X-Upstream-End", "response-ok") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"code":"NOT_FOUND"}`)) + })) + defer upstream.Close() + + bff := newPhase2Server(t, upstream.URL) + defer bff.Close() + + resp, err := http.Get(bff.URL + "/me/consents/550e8400-e29b-41d4-a716-446655440999") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } + for _, name := range []string{"Connection", "Keep-Alive", "TE", "Upgrade", "X-Upstream-Hop"} { + if got := resp.Header.Get(name); got != "" { + t.Fatalf("expected %s to be stripped, got %q", name, got) + } + } + if got := resp.Header.Get("X-Upstream-End"); got != "response-ok" { + t.Fatalf("expected end-to-end header to be forwarded, got %q", got) + } +} + +func TestApproveAndRevokeMappings(t *testing.T) { + t.Run("approve fetches consent and builds put payload", func(t *testing.T) { + var gotMethod string + var gotPath string + var gotBody map[string]any + var gotTPPClientID string + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/consents/550e8400-e29b-41d4-a716-446655440000": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "id":"550e8400-e29b-41d4-a716-446655440000", + "clientId":"TPP-CLIENT-001", + "type":"accounts", + "status":"CREATED", + "frequency":0, + "validityTime":0, + "recurringIndicator":false, + "dataAccessValidityDuration":86400, + "attributes":{"department":"sales","region":"APAC"}, + "authorizations":[ + {"id":"auth-1","userId":"user1@example.com","type":"authorisation","status":"APPROVED","updatedTime":1702800000,"resources":{"accountIds":["acc-123","acc-456"]}} + ], + "purposes":[ + {"name":"profile_access","elements":[ + {"name":"first_name","isUserApproved":false,"value":{}}, + {"name":"last_name","isUserApproved":false,"value":{}}, + {"name":"email","isUserApproved":true,"value":{}} + ]} + ] + }`)) + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/consent-purposes": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "data":[ + {"name":"profile_access","clientId":"TPP-CLIENT-001","description":null,"elements":[ + {"name":"first_name","isMandatory":true}, + {"name":"last_name","isMandatory":false}, + {"name":"email","isMandatory":false} + ]} + ] + }`)) + case r.Method == http.MethodPut && r.URL.Path == "/api/v1/consents/550e8400-e29b-41d4-a716-446655440000": + gotMethod = r.Method + gotPath = r.URL.Path + gotTPPClientID = r.Header.Get("TPP-client-id") + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &gotBody) + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer upstream.Close() + + cfg, err := config.Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + cfg.Proxy.OpenFGCAPIURL = upstream.URL + cfg.Proxy.PlaceholderModeEnabled = true + cfg.Proxy.PlaceholderUserID = "user@example.com" + cfg.Proxy.PlaceholderOrgID = "ORG-001" + cfg.Proxy.PlaceholderClientID = "PLACEHOLDER-CLIENT-999" + + h, err := newIntegrationHandler(*cfg) + if err != nil { + t.Fatalf("failed to create handler: %v", err) + } + bff := httptest.NewServer(h) + defer bff.Close() + + payload := []byte(`[{"purposeName":"profile_access","elementName":"last_name"}]`) + resp, err := http.Post(bff.URL+"/me/consents/550e8400-e29b-41d4-a716-446655440000/approve", "application/json", bytes.NewReader(payload)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if gotMethod != http.MethodPut { + t.Fatalf("expected PUT, got %s", gotMethod) + } + if gotPath != "/api/v1/consents/550e8400-e29b-41d4-a716-446655440000" { + t.Fatalf("unexpected path: %s", gotPath) + } + if gotTPPClientID != "TPP-CLIENT-001" { + t.Fatalf("expected TPP-client-id from consent clientId, got %s", gotTPPClientID) + } + if gotBody["type"] != "accounts" { + t.Fatalf("expected type accounts, got %v", gotBody["type"]) + } + if gotBody["frequency"] != float64(0) { + t.Fatalf("expected frequency 0, got %v", gotBody["frequency"]) + } + if gotBody["validityTime"] != float64(0) { + t.Fatalf("expected validityTime 0, got %v", gotBody["validityTime"]) + } + if gotBody["recurringIndicator"] != false { + t.Fatalf("expected recurringIndicator false, got %v", gotBody["recurringIndicator"]) + } + + purposes, ok := gotBody["purposes"].([]any) + if !ok || len(purposes) != 1 { + t.Fatalf("expected one purpose, got %v", gotBody["purposes"]) + } + purpose, ok := purposes[0].(map[string]any) + if !ok { + t.Fatalf("expected purpose object, got %T", purposes[0]) + } + elements, ok := purpose["elements"].([]any) + if !ok || len(elements) != 3 { + t.Fatalf("expected three elements, got %v", purpose["elements"]) + } + mandatoryElement, ok := elements[0].(map[string]any) + if !ok { + t.Fatalf("expected first element object, got %T", elements[0]) + } + if mandatoryElement["isUserApproved"] != true { + t.Fatalf("expected mandatory element approved, got %v", mandatoryElement["isUserApproved"]) + } + optionalElement, ok := elements[1].(map[string]any) + if !ok { + t.Fatalf("expected optional element object, got %T", elements[1]) + } + if optionalElement["isUserApproved"] != true { + t.Fatalf("expected selected optional element approved, got %v", optionalElement["isUserApproved"]) + } + omittedOptionalElement, ok := elements[2].(map[string]any) + if !ok { + t.Fatalf("expected omitted optional element object, got %T", elements[2]) + } + if omittedOptionalElement["isUserApproved"] != true { + t.Fatalf("expected omitted optional element to keep existing approval, got %v", omittedOptionalElement["isUserApproved"]) + } + + authorizations, ok := gotBody["authorizations"].([]any) + if !ok || len(authorizations) != 2 { + t.Fatalf("expected existing authorization plus current user authorization, got %v", gotBody["authorizations"]) + } + existingAuthorization, ok := authorizations[0].(map[string]any) + if !ok { + t.Fatalf("expected existing authorization object, got %T", authorizations[0]) + } + if existingAuthorization["userId"] != "user1@example.com" { + t.Fatalf("expected existing user id to be preserved, got %v", existingAuthorization["userId"]) + } + updatedAuthorization, ok := authorizations[1].(map[string]any) + if !ok { + t.Fatalf("expected current user authorization object, got %T", authorizations[1]) + } + if updatedAuthorization["userId"] != "user@example.com" { + t.Fatalf("expected current user id, got %v", updatedAuthorization["userId"]) + } + if updatedAuthorization["status"] != "APPROVED" { + t.Fatalf("expected approved authorization status, got %v", updatedAuthorization["status"]) + } + if updatedAuthorization["type"] != "authorisation" { + t.Fatalf("expected authorization type authorisation, got %v", updatedAuthorization["type"]) + } + if resources, exists := updatedAuthorization["resources"]; !exists || resources == nil { + t.Fatalf("expected resources to be present as an empty object, got %v", updatedAuthorization["resources"]) + } + }) + + t.Run("revoke maps to revoke endpoint", func(t *testing.T) { + var gotMethod string + var gotPath string + var gotBody map[string]any + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &gotBody) + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + bff := newPhase2Server(t, upstream.URL) + defer bff.Close() + + req, err := http.NewRequest(http.MethodPut, bff.URL+"/me/consents/550e8400-e29b-41d4-a716-446655440000/revoke", bytes.NewReader([]byte(`{"revocationReason":"test"}`))) + if err != nil { + t.Fatalf("request creation failed: %v", err) + } + 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.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if gotMethod != http.MethodPut { + t.Fatalf("expected PUT, got %s", gotMethod) + } + if gotPath != "/api/v1/consents/550e8400-e29b-41d4-a716-446655440000/revoke" { + t.Fatalf("unexpected path: %s", gotPath) + } + if gotBody["actionBy"] != "user@example.com" { + t.Fatalf("expected placeholder actionBy, got %v", gotBody["actionBy"]) + } + if gotBody["revocationReason"] != "test" { + t.Fatalf("expected revocationReason passthrough, got %v", gotBody["revocationReason"]) + } + }) + + t.Run("revoke accepts null payload", func(t *testing.T) { + var gotMethod string + var gotPath string + var gotBody map[string]any + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &gotBody) + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + bff := newPhase2Server(t, upstream.URL) + defer bff.Close() + + req, err := http.NewRequest(http.MethodPut, bff.URL+"/me/consents/550e8400-e29b-41d4-a716-446655440000/revoke", bytes.NewReader([]byte(`null`))) + if err != nil { + t.Fatalf("request creation failed: %v", err) + } + 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.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + if gotMethod != http.MethodPut { + t.Fatalf("expected PUT, got %s", gotMethod) + } + if gotPath != "/api/v1/consents/550e8400-e29b-41d4-a716-446655440000/revoke" { + t.Fatalf("unexpected path: %s", gotPath) + } + if gotBody["actionBy"] != "user@example.com" { + t.Fatalf("expected placeholder actionBy, got %v", gotBody["actionBy"]) + } + if len(gotBody) != 1 { + t.Fatalf("expected only actionBy in payload, got %v", gotBody) + } + }) +} + +func TestAPIDenyByDefault(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + bff := newPhase2Server(t, upstream.URL) + defer bff.Close() + + t.Run("unknown path returns 404", func(t *testing.T) { + resp, err := http.Get(bff.URL + "/api/unknown/resource") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } + }) + + t.Run("unknown path with disallowed method returns 404", func(t *testing.T) { + req, err := http.NewRequest(http.MethodTrace, bff.URL+"/api/unknown/resource", nil) + if err != nil { + t.Fatalf("request creation failed: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404, got %d", resp.StatusCode) + } + }) + + t.Run("known path wrong method returns 405", func(t *testing.T) { + req, err := http.NewRequest(http.MethodDelete, bff.URL+"/api/consents", nil) + if err != nil { + t.Fatalf("request creation failed: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", resp.StatusCode) + } + }) +} + +func TestProxyTimeoutMapsTo504(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(120 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + bff := newPhase2ServerWithTimeout(t, upstream.URL, 40*time.Millisecond) + defer bff.Close() + + resp, err := http.Get(bff.URL + "/api/consents") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusGatewayTimeout { + t.Fatalf("expected 504, got %d", resp.StatusCode) + } + + 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"] != "UPSTREAM_TIMEOUT" { + t.Fatalf("expected code UPSTREAM_TIMEOUT, got %v", payload["code"]) + } +} + +func TestRequestBodySizeLimitReturns413(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + bff := newPhase2ServerWithMaxBytes(t, upstream.URL, 8) + defer bff.Close() + + t.Run("api passthrough returns 413 with json error", func(t *testing.T) { + big := bytes.Repeat([]byte("a"), 32) + req, err := http.NewRequest(http.MethodPost, bff.URL+"/api/consents", bytes.NewReader(big)) + if err != nil { + t.Fatalf("request creation failed: %v", err) + } + 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.StatusRequestEntityTooLarge { + t.Fatalf("expected 413, got %d", resp.StatusCode) + } + if got := resp.Header.Get("Content-Type"); got != "application/json" { + t.Fatalf("expected content type application/json, got %s", got) + } + 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"] != "REQUEST_TOO_LARGE" { + t.Fatalf("expected code REQUEST_TOO_LARGE, got %v", payload["code"]) + } + }) + + t.Run("me approve returns 413 with json error", func(t *testing.T) { + big := bytes.Repeat([]byte("b"), 64) + resp, err := http.Post(bff.URL+"/me/consents/550e8400-e29b-41d4-a716-446655440001/approve", "application/json", bytes.NewReader(big)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusRequestEntityTooLarge { + t.Fatalf("expected 413, got %d", resp.StatusCode) + } + 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"] != "REQUEST_TOO_LARGE" { + t.Fatalf("expected code REQUEST_TOO_LARGE, got %v", payload["code"]) + } + }) +} + +func TestRequestBodyReadFailureReturnsBadRequest(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() + + cfg, err := config.Load() + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + cfg.Proxy.OpenFGCAPIURL = upstream.URL + + h, err := newIntegrationHandler(*cfg) + if err != nil { + t.Fatalf("failed to create handler: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/consents", nil) + req.Body = failingReadCloser{} + res := httptest.NewRecorder() + + h.ServeHTTP(res, req) + + if res.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", res.Code) + } + var payload map[string]any + if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { + t.Fatalf("expected json error payload: %v", err) + } + if payload["code"] != "INVALID_REQUEST_BODY" { + t.Fatalf("expected code INVALID_REQUEST_BODY, got %v", payload["code"]) + } + if upstreamCalled { + t.Fatalf("expected upstream not to be called") + } +} + +func TestUpstreamUnavailableMapsTo502(t *testing.T) { + bff := newPhase2Server(t, "http://127.0.0.1:1") + defer bff.Close() + + resp, err := http.Get(bff.URL + "/api/consents") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusBadGateway { + t.Fatalf("expected 502, got %d", resp.StatusCode) + } + + 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"] != "UPSTREAM_UNAVAILABLE" { + t.Fatalf("expected code UPSTREAM_UNAVAILABLE, got %v", payload["code"]) + } +} + +func TestMeEndpointsReturn503WhenPlaceholderModeDisabled(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 := newPhase2ServerPlaceholderDisabled(t, upstream.URL) + defer bff.Close() + + testCases := []struct { + name string + method string + path string + body string + }{ + {name: "me consents", method: http.MethodGet, path: "/me/consents"}, + {name: "me consent by id", method: http.MethodGet, path: "/me/consents/550e8400-e29b-41d4-a716-446655440000"}, + {name: "me approve", method: http.MethodPost, path: "/me/consents/550e8400-e29b-41d4-a716-446655440000/approve", body: "[]"}, + {name: "me revoke", method: http.MethodPut, path: "/me/consents/550e8400-e29b-41d4-a716-446655440000/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.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", resp.StatusCode) + } + + if upstreamCalled { + t.Fatal("expected request to be blocked 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"] != "IDENTITY_UNAVAILABLE" { + t.Fatalf("expected IDENTITY_UNAVAILABLE, got %v", payload["code"]) + } + }) + } +} + +func TestMeConsentByIDAggregatesPurposeAndElementDetails(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/consents/550e8400-e29b-41d4-a716-446655440000": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "id":"550e8400-e29b-41d4-a716-446655440000", + "clientId":"TPP-CLIENT-001", + "type":"accounts", + "status":"ACTIVE", + "createdTime":1702800000, + "updatedTime":1702800001, + "purposes":[ + { + "name":"marketing_communication_preferences", + "elements":[ + {"name":"user_email","isUserApproved":true} + ] + } + ] + }`)) + case "/api/v1/consent-purposes": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "data":[ + { + "name":"marketing_communication_preferences", + "description":"Marketing communication consent", + "elements":[ + {"name":"user_email","isMandatory":true} + ] + } + ] + }`)) + case "/api/v1/consent-elements": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "data":[ + { + "name":"user_email", + "type":"basic", + "description":"User email address", + "properties":{"channel":"email"} + } + ] + }`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer upstream.Close() + + bff := newPhase2Server(t, upstream.URL) + defer bff.Close() + + resp, err := http.Get(bff.URL + "/me/consents/550e8400-e29b-41d4-a716-446655440000") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var payload map[string]any + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatalf("expected json response: %v", err) + } + + purposes, ok := payload["purposes"].([]any) + if !ok || len(purposes) != 1 { + t.Fatalf("expected one purpose, got %v", payload["purposes"]) + } + purpose, ok := purposes[0].(map[string]any) + if !ok { + t.Fatalf("expected purpose object, got %T", purposes[0]) + } + if purpose["description"] != "Marketing communication consent" { + t.Fatalf("expected purpose description to be enriched, got %v", purpose["description"]) + } + + elements, ok := purpose["elements"].([]any) + if !ok || len(elements) != 1 { + t.Fatalf("expected one element, got %v", purpose["elements"]) + } + element, ok := elements[0].(map[string]any) + if !ok { + t.Fatalf("expected element object, got %T", elements[0]) + } + if mandatory, ok := element["isMandatory"].(bool); !ok || !mandatory { + t.Fatalf("expected isMandatory=true, got %v", element["isMandatory"]) + } + if element["type"] != "basic" { + t.Fatalf("expected enriched element type, got %v", element["type"]) + } + if element["description"] != "User email address" { + t.Fatalf("expected enriched element description, got %v", element["description"]) + } +} + +func TestMeConsentByIDFailsClosedWhenPurposeMetadataMissing(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/consents/550e8400-e29b-41d4-a716-446655440000": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "id":"550e8400-e29b-41d4-a716-446655440000", + "clientId":"TPP-CLIENT-001", + "type":"accounts", + "status":"ACTIVE", + "createdTime":1702800000, + "updatedTime":1702800001, + "purposes":[{"name":"marketing_communication_preferences","elements":[{"name":"user_email","isUserApproved":true}]}] + }`)) + case "/api/v1/consent-purposes": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer upstream.Close() + + bff := newPhase2Server(t, upstream.URL) + defer bff.Close() + + resp, err := http.Get(bff.URL + "/me/consents/550e8400-e29b-41d4-a716-446655440000") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusBadGateway { + t.Fatalf("expected 502, got %d", resp.StatusCode) + } + + 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"] != "UPSTREAM_UNAVAILABLE" { + t.Fatalf("expected code UPSTREAM_UNAVAILABLE, got %v", payload["code"]) + } +} + +func TestMeConsentByIDHandlesNullableAndMixedProperties(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/consents/550e8400-e29b-41d4-a716-446655440000": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "id":"550e8400-e29b-41d4-a716-446655440000", + "clientId":"TPP-CLIENT-001", + "type":"accounts", + "status":"ACTIVE", + "frequency":null, + "validityTime":null, + "recurringIndicator":null, + "dataAccessValidityDuration":null, + "attributes":{"consentMode":1}, + "authorizations":[{"id":"auth-1","userId":null,"type":"authorisation","status":"APPROVED","updatedTime":1702800002}], + "createdTime":1702800000, + "updatedTime":1702800001, + "purposes":[{"name":"marketing_communication_preferences","elements":[{"name":"user_email","isUserApproved":true}]}] + }`)) + case "/api/v1/consent-purposes": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "data":[{"name":"marketing_communication_preferences","description":null,"elements":[{"name":"user_email","isMandatory":true}]}] + }`)) + case "/api/v1/consent-elements": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "data":[{"name":"user_email","type":"json-payload","description":null,"properties":{"validationSchema":{"type":"object"}}}] + }`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer upstream.Close() + + bff := newPhase2Server(t, upstream.URL) + defer bff.Close() + + resp, err := http.Get(bff.URL + "/me/consents/550e8400-e29b-41d4-a716-446655440000") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var payload map[string]any + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatalf("expected json response: %v", err) + } + + purposes, ok := payload["purposes"].([]any) + if !ok || len(purposes) != 1 { + t.Fatalf("expected one purpose, got %v", payload["purposes"]) + } + purpose := purposes[0].(map[string]any) + elements := purpose["elements"].([]any) + element := elements[0].(map[string]any) + + if mandatory, ok := element["isMandatory"].(bool); !ok || !mandatory { + t.Fatalf("expected isMandatory=true, got %v", element["isMandatory"]) + } + if element["type"] != "json-payload" { + t.Fatalf("expected enriched element type, got %v", element["type"]) + } + properties, ok := element["properties"].(map[string]any) + if !ok { + t.Fatalf("expected properties map, got %T", element["properties"]) + } + if _, ok := properties["validationSchema"].(map[string]any); !ok { + t.Fatalf("expected validationSchema object, got %T", properties["validationSchema"]) + } +} + +func TestMeConsentByIDPurposeLookupFallsBackWithoutClientFilter(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/consents/550e8400-e29b-41d4-a716-446655440000": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{ + "id":"550e8400-e29b-41d4-a716-446655440000", + "clientId":"TPP-CLIENT-003", + "type":"accounts", + "status":"ACTIVE", + "createdTime":1702800000, + "updatedTime":1702800001, + "purposes":[{"name":"data_sharing_purpose","elements":[{"name":"user_email","isUserApproved":true}]}] + }`)) + case "/api/v1/consent-purposes": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if strings.Contains(r.URL.RawQuery, "clientIds=TPP-CLIENT-003") { + _, _ = w.Write([]byte(`{"data":[]}`)) + return + } + _, _ = w.Write([]byte(`{ + "data":[{"clientId":"TPP-CLIENT-002","name":"data_sharing_purpose","description":"Third-party data sharing purpose","elements":[{"name":"user_email","isMandatory":false}]}] + }`)) + case "/api/v1/consent-elements": + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[{"name":"user_email","type":"basic","description":"User email","properties":{}}]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer upstream.Close() + + bff := newPhase2Server(t, upstream.URL) + defer bff.Close() + + resp, err := http.Get(bff.URL + "/me/consents/550e8400-e29b-41d4-a716-446655440000") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + var payload map[string]any + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + t.Fatalf("expected json response: %v", err) + } + purposes, ok := payload["purposes"].([]any) + if !ok || len(purposes) != 1 { + t.Fatalf("expected one purpose, got %v", payload["purposes"]) + } + purpose := purposes[0].(map[string]any) + if purpose["description"] != "Third-party data sharing purpose" { + t.Fatalf("expected fallback purpose description, got %v", purpose["description"]) + } +} diff --git a/portal/backend/tests/integration/server_test.go b/portal/backend/tests/integration/server_test.go new file mode 100644 index 00000000..1301461a --- /dev/null +++ b/portal/backend/tests/integration/server_test.go @@ -0,0 +1,37 @@ +package integration + +import ( + "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/config" + "github.com/wso2/openfgc/portal/backend/internal/system/healthcheck" + systemlog "github.com/wso2/openfgc/portal/backend/internal/system/log" + "github.com/wso2/openfgc/portal/backend/internal/system/middleware" +) + +func newIntegrationHandler(cfg config.Config) (http.Handler, error) { + mux := http.NewServeMux() + + healthHandler := healthcheck.NewHandler() + mux.HandleFunc("GET /health/liveness", healthHandler.Liveness) + mux.HandleFunc("GET /health/readiness", healthHandler.Readiness) + mux.HandleFunc("GET /health", healthHandler.Liveness) + + if err := proxy.Initialize(mux, cfg.Proxy); err != nil { + return nil, err + } + if err := me.Initialize(mux, cfg); err != nil { + return nil, err + } + + withCORS := middleware.CORS(mux, middleware.CORSOptions{ + AllowedOrigins: cfg.CORS.AllowedOrigins, + AllowedMethods: cfg.CORS.AllowedMethods, + AllowedHeaders: cfg.CORS.AllowedHeaders, + AllowCredentials: cfg.CORS.AllowCredentials, + }) + + return middleware.CorrelationID(systemlog.New(cfg.Log.Level), withCORS), nil +} diff --git a/portal/bff-implementation-phases.md b/portal/bff-implementation-phases.md new file mode 100644 index 00000000..8697a5d5 --- /dev/null +++ b/portal/bff-implementation-phases.md @@ -0,0 +1,67 @@ +## Plan: Phased BFF Implementation + +The phases below include your requirement that AI guidance files are created in Phase 1, while deployment happens only after all phases complete. + +**Phase 1: Bootstrap, standards, and tooling (blocks all later phases)** +Goal: establish a runnable project foundation with quality controls. + +1. Create BFF project skeleton and core package layout. +2. Add startup, health endpoint, config loader, logging, router wiring, and graceful shutdown. +3. Set up linting and code quality checks in local scripts and CI. +4. Add AI guidance artifacts as mandatory project assets: + - AGENTS.md + - .github/copilot-instructions.md +5. Add initial test scaffolding for unit and integration tests. +6. Add initial OpenAPI contract placeholders for BFF routes. + +Exit criteria: +1. Service starts and health endpoint returns success. +2. Lint, format, and baseline tests pass in CI. +3. AI guidance files exist and are referenced in contributor docs. + +**Phase 2: Proxy layer with config-driven identity placeholders (depends on Phase 1)** +Goal: validate proxy behavior independently from auth. + +1. Implement proxy path mapping, method/path allowlist, timeout policy, and body limits. +2. Introduce portal user endpoints in proxy mode (for example, `/me/consents`, `/me/consents/{consentId}`, `/me/consents/{consentId}/approve`, `/me/consents/{consentId}/revoke`) with explicit route mappings to upstream `/api/v1/*` contracts. +3. Add secure header handling: + - strip hop-by-hop headers + - ignore client-supplied trusted headers + - propagate/generate correlation id +4. Inject currently unavailable auth-dependent values from config for test mode. +5. Add hard guardrails so placeholder mode cannot run in production. +6. Add integration tests for rewrite logic, header safety, query preservation, error mapping, and portal user endpoint route mappings in placeholder mode. +7. Defer user ownership enforcement and principal-derived scoping for `/me/*` endpoints to Phase 3 when auth/session context is available. + +Exit criteria: +1. Proxy behavior is fully testable without auth integration. +2. Security checks on headers and route allowlisting pass. +3. Portal user endpoint mappings are implemented and validated in placeholder mode. +4. Placeholder identity mode is explicitly restricted to non-production use. + +**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. +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. + +Exit criteria: +1. Full authenticated request lifecycle works end to end. +2. Refresh/logout error mappings and cookie behaviors match spec. +3. Config placeholder mode is off by default and not used in normal runtime. + +**Phase 4: Hardening and release readiness (depends on Phase 3)** +Goal: finalize operational safety and production readiness. + +1. Validate key rotation, session cookie size limits, refresh race behavior, and timeout handling. +2. Complete deployment and operations documentation. +3. Run full regression suite in local compose and staging-like setup. +4. Finalize release packaging and CI gates. + +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 diff --git a/portal/bff-implementation-plan.md b/portal/bff-implementation-plan.md new file mode 100644 index 00000000..aa01542e --- /dev/null +++ b/portal/bff-implementation-plan.md @@ -0,0 +1,1039 @@ +# Stateless BFF Implementation Plan +**OAuth/OIDC · Portal · OpenFGC Integration (IdP-Agnostic)** + +--- + +## 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 │ +└──────────┘ └────────────────────┘ +``` + +### 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` + +--- + +## 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. | + +--- + +## 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) + +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. + +``` +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. + +``` +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 +``` + +### 3.4 Cookie 3 — `csrf_token` (non-HttpOnly) + +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. + +``` +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 +``` + +### 3.5 Cookie Comparison + +| 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 | + +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. + +### 3.6 Why Not Encrypt Cookie 1? + +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. + +### 3.7 Recommended Cookie Prefix Hardening + +For production deployments, prefer the `__Host-` cookie prefix for all BFF cookies (for example: `__Host-auth_token`, `__Host-session_token`, `__Host-csrf_token`). + +`__Host-` requires all of the following: +- `Secure=true` +- `Path=/` +- No `Domain` attribute + +These constraints prevent accidental subdomain scoping and reduce cookie injection risk from sibling subdomains. + +If `__Host-` naming is enabled, `COOKIE_DOMAIN` must remain empty in all environments. + +--- + +## 4. Authentication Flows + +### 4.1 OIDC Login + +``` +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 +``` + +### 4.2 Authenticated Request + +``` +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 +``` + +### 4.3 Token Refresh + +``` +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 +``` + +> **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). + +### 4.4 Logout + +``` +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. +``` + +--- + +## 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 + +--- + +## 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) +``` + +### 6.2 Request Flow + +``` +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 +``` + +--- + +## 7. Core Functions & Signatures + +### 7.1 Cookie 1 — Identity Token (JWS) + +```go +// Generate RS256-signed JWT containing identity claims and a kid header +func GenerateAuthToken(claims IdentityClaims, privateKey *rsa.PrivateKey, keyVersion string) (string, error) + +// 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) +``` + +### 7.2 Cookie 2 — Session Token (JWE) + +```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) + +// 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) +``` + +### 7.3 CSRF + +```go +// HMAC-SHA256(sub + iat, csrfHMACKey) — tied to user identity +func GenerateCSRFToken(sub string, iat int64, hmacKey []byte) string + +// Verify double-submit equality and HMAC authenticity in constant time +func ValidateCSRFToken(cookieValue, headerValue, sub string, iat int64, hmacKey []byte) bool +``` + +### 7.4 OIDC State + +```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) + +// 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 +``` + +### 7.6 OIDC Exchange + +```go +// Exchange authorization code for IdP tokens (PKCE enabled) +func ExchangeCodeForToken(code, codeVerifier string) (*OIDCTokenResponse, error) + +// Use refresh_token to obtain new access token +// Returns ErrInvalidGrant if IdP rejects (rotation consumed the token) +func RefreshAccessToken(refreshToken string) (*OIDCTokenResponse, error) + +// Build provider end_session URL for RP-initiated logout (if supported) +func BuildLogoutURL(postLogoutRedirectURI string) string +``` + +### 7.7 Proxy + +```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) +``` + +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) +``` + +--- + +## 8. Security + +### 8.1 XSS Protection + +- `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)) + +### 8.2 CSRF Protection + +- `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. + +### 8.3 Token Security + +- 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) + +### 8.4 Key Management + +| 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 | + +- 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 + +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. + +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. + +### 8.6 Provider Token Assumptions (Header-Trust Backend Mode) + +In this deployment mode, OpenFGC does not verify bearer tokens and trusts upstream headers from the BFF. + +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 + +JWT access tokens are recommended but not mandatory for proxying in this mode, because the BFF does not forward bearer tokens to OpenFGC. + +**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. + +### 8.7 Security Headers + +Applied by `SecurityHeadersMiddleware` on all responses: + +``` +Content-Security-Policy: default-src 'none' +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +Referrer-Policy: strict-origin-when-cross-origin +``` + +Caching policy (required on auth/session-sensitive responses): + +``` +Cache-Control: no-store +Pragma: no-cache +``` + +For CORS responses, include: + +``` +Vary: Origin +``` + +`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. + +### 8.8 Session Cookie Size Note + +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`. + +--- + +## 9. Project Structure + +``` +portal/backend/ +├── cmd/ +│ └── server/ +│ ├── main.go # Entry point, router setup +│ └── servicemanager.go # Service and middleware initialisation +│ +├── 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 +│ │ +│ ├── 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 +│ +├── 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 +├── go.mod +├── Dockerfile +├── docker-compose.yml +└── Makefile +``` + +--- + +## 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. + +```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 +``` + +--- + +## 11. Deployment Notes + +### 11.1 Rate Limiting + +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 + +### 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. + +**Rotation steps (JWE / AES key):** + +``` +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. +``` + +**Rotation steps (RSA keypair / JWT signing key):** + +``` +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 + +If the selected IdP uses a self-signed certificate in local development, TLS verification will fail in the BFF container. Resolution options: + +```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 +``` + +Do **not** use `InsecureSkipVerify: true` in any environment other than local development, and never commit it without an explicit warning comment. + +### 11.6 Header-Trust Backend Boundary (Mandatory) + +When OpenFGC trusts upstream headers and does not verify bearer tokens, network boundary controls are mandatory: + +- 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 + +--- + +## 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 + +--- + +## 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