Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/portal-frontend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Audit production dependencies
run: pnpm security:audit

- name: Lint
run: pnpm lint

Expand Down
40 changes: 37 additions & 3 deletions portal/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ BFF_ENV=development
# CORS (frontend integration)
BFF_CORS__ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173
BFF_CORS__ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS
BFF_CORS__ALLOWED_HEADERS=Content-Type,X-Correlation-ID
BFF_CORS__ALLOW_CREDENTIALS=false
BFF_CORS__ALLOWED_HEADERS=Content-Type,Authorization,X-Correlation-ID
BFF_CORS__ALLOW_CREDENTIALS=true

# Proxy
BFF_PROXY__OPENFGC_API_URL=http://localhost:8060
Expand All @@ -32,4 +32,38 @@ BFF_PROXY__ALLOWED_PASSTHROUGH_METHODS=["GET","POST","PUT","DELETE"]
BFF_PROXY__PLACEHOLDER_MODE_ENABLED=false
BFF_PROXY__PLACEHOLDER_USER_ID=
BFF_PROXY__PLACEHOLDER_ORG_ID=
BFF_PROXY__PLACEHOLDER_GROUP_ID=

# OIDC confidential-client authentication
BFF_AUTH__ENABLED=false
BFF_AUTH__ISSUER_URL=
BFF_AUTH__CLIENT_ID=
# Environment only; never place this secret in a YAML config file.
BFF_AUTH__CLIENT_SECRET=
BFF_AUTH__PORTAL_URL=http://localhost:5173/consents
BFF_AUTH__REDIRECT_URI=http://localhost:8080/auth/callback
BFF_AUTH__POST_LOGOUT_REDIRECT_URI=http://localhost:5173/
BFF_AUTH__SCOPES=openid email profile portal:consents:read:self portal:consents:write:self
BFF_AUTH__RESOURCE_AUDIENCE=
BFF_AUTH__ALLOWED_SIGNING_ALGORITHMS=RS256
BFF_AUTH__HTTP_TIMEOUT=5s
BFF_AUTH__REFRESH_TIMEOUT=10s
BFF_AUTH__CLOCK_SKEW=30s
BFF_AUTH__SCOPE_CLAIM=scope
BFF_AUTH__ORG_ID_CLAIM=org_id
BFF_AUTH__REQUIRE_ACCESS_TOKEN_TYPE=false
BFF_AUTH__ACCESS_TOKEN_TYPE_CLAIM=token_type
BFF_AUTH__ACCESS_TOKEN_TYPE_VALUE=access_token
BFF_AUTH__ACCESS_TOKEN_PART1_COOKIE=portal-at-p1
BFF_AUTH__ACCESS_TOKEN_PART2_COOKIE=portal-at-p2
BFF_AUTH__REFRESH_TOKEN_PART1_COOKIE=portal-rt-p1
BFF_AUTH__REFRESH_TOKEN_PART2_COOKIE=portal-rt-p2
BFF_AUTH__ID_TOKEN_PART1_COOKIE=portal-id-p1
BFF_AUTH__ID_TOKEN_PART2_COOKIE=portal-id-p2
BFF_AUTH__OAUTH_STATE_COOKIE=portal-oauth-state
BFF_AUTH__PKCE_VERIFIER_COOKIE=portal-pkce-verifier
BFF_AUTH__COOKIE_SECURE=false
BFF_AUTH__COOKIE_SAME_SITE=Lax
Comment thread
anjuchamantha marked this conversation as resolved.
Resolved
BFF_AUTH__LOGIN_TRANSACTION_MAX_AGE_SECONDS=600
BFF_AUTH__REFRESH_COOKIE_MAX_AGE_SECONDS=86400
BFF_AUTH__MAX_TOKEN_PART_BYTES=3800
BFF_AUTH__MAX_RECONSTRUCTED_TOKEN_BYTES=7600
13 changes: 11 additions & 2 deletions portal/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ When credentials are enabled, origins must be explicitly allowlisted (wildcard o

Portal-facing user endpoints:

- `GET /me/consents` -> upstream `GET /api/v1/consents` with forced `userIds=<placeholder>` and `details=true`
- `GET /me/consents` -> upstream `GET /api/v1/consents` with forced `userIds=<validated subject>` and `details=true`
- `GET /me/consents/{consentId}` -> one upstream `GET /api/v1/consents/{consentId}?details=true`; the detailed consent snapshot is returned unchanged
- `POST /me/consents/{consentId}/approve` -> BFF fetches the current consent with `details=true`, validates selected optional elements against its embedded stable IDs and versions, automatically approves mandatory elements, preserves existing approvals, and sends a full upstream consent update with the consent's immutable `groupId` as the trusted `group-id` header
- `POST /me/consents/{consentId}/revoke` -> upstream `POST /api/v1/consents/{consentId}/revoke`
Expand All @@ -75,6 +75,16 @@ Proxy hardening:
- Request body limit enforcement (`BFF_PROXY__MAX_REQUEST_BYTES`) with `413`
- Deterministic upstream error mapping: timeout -> `504`, other connectivity failures -> `502`

Authentication endpoints:

- `GET /auth/login` starts confidential-client OIDC login
- `GET /auth/callback` validates returned tokens through discovery/JWKS and issues split cookies
- `POST /auth/refresh` reconstructs the split refresh token and returns `204`
- `POST /auth/logout` clears cookies and returns the configured or discovered logout URL

Set `BFF_AUTH__ENABLED=true` to use OIDC authentication. The client secret is read only from
`BFF_AUTH__CLIENT_SECRET`; see `.env.example` for the complete configuration contract.

Error contract for proxy-originated failures:

```json
Expand All @@ -98,7 +108,6 @@ Common error codes:
- `BFF_PROXY__PLACEHOLDER_MODE_ENABLED=true` is blocked when `BFF_ENV=production`
- `BFF_PROXY__PLACEHOLDER_USER_ID` must be empty if placeholder mode is disabled
- `BFF_PROXY__PLACEHOLDER_ORG_ID` must be empty if placeholder mode is disabled
- `BFF_PROXY__PLACEHOLDER_GROUP_ID` must be empty if placeholder mode is disabled

## AI Instructions

Expand Down
13 changes: 11 additions & 2 deletions portal/backend/cmd/server/servicemanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
package main

import (
"context"
"log/slog"
"net/http"

"github.com/wso2/openfgc/portal/backend/internal/me"
"github.com/wso2/openfgc/portal/backend/internal/proxy"
"github.com/wso2/openfgc/portal/backend/internal/system/auth"
"github.com/wso2/openfgc/portal/backend/internal/system/config"
"github.com/wso2/openfgc/portal/backend/internal/system/healthcheck"
)
Expand All @@ -35,12 +37,19 @@ func registerServices(mux *http.ServeMux, log *slog.Logger, cfg config.Config) e
mux.HandleFunc("GET /health", healthHandler.Liveness)
log.Debug("registered health endpoints")

if err := proxy.Initialize(mux, cfg.Proxy); err != nil {
authManager, err := auth.NewManager(context.Background(), cfg.Auth, cfg.Proxy, log)
if err != nil {
return err
}
authManager.RegisterRoutes(mux)
log.Debug("registered auth module")

if err := proxy.Initialize(mux, cfg.Proxy, authManager); err != nil {
return err
}
log.Debug("registered proxy module")

if err := me.Initialize(mux, cfg); err != nil {
if err := me.Initialize(mux, cfg, authManager); err != nil {
return err
}
log.Debug("registered me module")
Expand Down
3 changes: 3 additions & 0 deletions portal/backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ module github.com/wso2/openfgc/portal/backend
go 1.26.3

require (
github.com/coreos/go-oidc/v3 v3.20.0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are these commonly used dependencies? Can you check the other dependencies wso2 go projects have used?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's create an issue to check the dependencies used.

github.com/go-jose/go-jose/v4 v4.1.4
github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/env v1.0.0
github.com/knadh/koanf/providers/file v1.2.0
github.com/knadh/koanf/v2 v2.3.0
golang.org/x/oauth2 v0.36.0
)

require (
Expand Down
6 changes: 6 additions & 0 deletions portal/backend/go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
Expand All @@ -28,6 +32,8 @@ github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcU
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
Expand Down
56 changes: 47 additions & 9 deletions portal/backend/internal/me/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,10 @@ func (h *Handler) ConsentByID(w http.ResponseWriter, r *http.Request) {
writeJSONError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
return
}
if _, ok := h.resolveUserID(w, r); !ok {
userID, ok := h.resolveUserID(w, r)
if !ok {
return
}
// TODO(Phase 3): verify the fetched consent belongs to the authenticated user before returning it.
consentID := r.PathValue("consentId")
if consentID == "" {
writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent id not found")
Expand All @@ -94,12 +94,27 @@ func (h *Handler) ConsentByID(w http.ResponseWriter, r *http.Request) {
return
}

if err := h.svc.Forward(w, r, http.MethodGet, "/api/v1/consents/"+url.PathEscape(consentID), func(q url.Values) {
baseResp, err := h.svc.ForwardRaw(r, http.MethodGet, "/api/v1/consents/"+url.PathEscape(consentID), func(q url.Values) {
q.Set("details", "true")
q.Del("includeStatusHistory")
}, nil); err != nil {
}, nil)
if err != nil {
writeProxyError(w, err)
return
}
if baseResp.StatusCode != http.StatusOK {
h.svc.WriteUpstreamResponse(w, baseResp)
return
}
if _, owned, ownershipErr := OwnedConsentGroupID(baseResp.Body, userID); ownershipErr != nil {
writeProxyError(w, ownershipErr)
return
} else if !owned {
writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent not found")
return
}

h.svc.WriteUpstreamResponse(w, baseResp)
}

// ConsentApprove handles POST /me/consents/{consentId}/approve.
Expand All @@ -112,7 +127,6 @@ func (h *Handler) ConsentApprove(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
// TODO(Phase 3): verify the fetched consent belongs to the authenticated user before approving it.
consentID := r.PathValue("consentId")
if consentID == "" {
writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent id not found")
Expand Down Expand Up @@ -144,6 +158,13 @@ func (h *Handler) ConsentApprove(w http.ResponseWriter, r *http.Request) {
h.svc.WriteUpstreamResponse(w, baseResp)
return
}
if _, owned, ownershipErr := OwnedConsentGroupID(baseResp.Body, userID); ownershipErr != nil {
writeProxyError(w, ownershipErr)
return
} else if !owned {
writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent not found")
return
}

payload, trustedGroupID, err := h.svc.BuildApprovalUpdatePayload(baseResp.Body, selections, userID)
if err != nil {
Expand All @@ -169,7 +190,6 @@ func (h *Handler) ConsentRevoke(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
// TODO(Phase 3): verify the targeted consent belongs to the authenticated user before revoking it.
consentID := r.PathValue("consentId")
if consentID == "" {
writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent id not found")
Expand All @@ -189,7 +209,25 @@ func (h *Handler) ConsentRevoke(w http.ResponseWriter, r *http.Request) {
writeJSONError(w, http.StatusBadRequest, "INVALID_PAYLOAD", "invalid request payload")
return
}
if err := h.svc.Forward(w, r, http.MethodPost, "/api/v1/consents/"+url.PathEscape(consentID)+"/revoke", nil, payload); err != nil {
baseResp, err := h.svc.ForwardRaw(r, http.MethodGet, "/api/v1/consents/"+url.PathEscape(consentID), nil, nil)
if err != nil {
writeProxyError(w, err)
return
}
if baseResp.StatusCode != http.StatusOK {
h.svc.WriteUpstreamResponse(w, baseResp)
return
}
trustedGroupID, owned, err := OwnedConsentGroupID(baseResp.Body, userID)
if err != nil {
writeProxyError(w, err)
return
}
if !owned {
writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent not found")
return
}
if err := h.svc.ForwardWithGroupID(w, r, http.MethodPost, "/api/v1/consents/"+url.PathEscape(consentID)+"/revoke", nil, payload, trustedGroupID); err != nil {
writeProxyError(w, err)
}
}
Expand Down Expand Up @@ -225,12 +263,12 @@ func writeBodyReadError(w http.ResponseWriter, err error) {
}

func (h *Handler) resolveUserID(w http.ResponseWriter, r *http.Request) (string, bool) {
userID, ok := systemcontext.UserIDFromContext(r.Context())
principal, ok := systemcontext.PrincipalFromContext(r.Context())
Comment thread
rashmithachamikara marked this conversation as resolved.
Resolved
if !ok {
writeJSONError(w, http.StatusServiceUnavailable, "PLACEHOLDER_UNAVAILABLE", "placeholder identity unavailable")
return "", false
}
return userID, true
return principal.UserID, true
}

func (h *Handler) readBoundedBody(r *http.Request) ([]byte, error) {
Expand Down
18 changes: 6 additions & 12 deletions portal/backend/internal/me/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,27 +21,21 @@ package me
import (
"net/http"

"github.com/wso2/openfgc/portal/backend/internal/system/auth"
"github.com/wso2/openfgc/portal/backend/internal/system/config"
"github.com/wso2/openfgc/portal/backend/internal/system/middleware"
)

// Initialize sets up the me module and registers routes.
func Initialize(mux *http.ServeMux, cfg config.Config) error {
func Initialize(mux *http.ServeMux, cfg config.Config, authManager *auth.Manager) error {
handler, err := NewHandler(cfg.Proxy)
if err != nil {
return err
}

userIDOptions := middleware.UserIDOptions{
PlaceholderModeEnabled: cfg.Proxy.PlaceholderModeEnabled,
PlaceholderUserID: cfg.Proxy.PlaceholderUserID,
Environment: cfg.Env,
}

mux.Handle("GET /me/consents", middleware.UserID(http.HandlerFunc(handler.Consents), userIDOptions))
mux.Handle("GET /me/consents/{consentId}", middleware.UserID(http.HandlerFunc(handler.ConsentByID), userIDOptions))
mux.Handle("POST /me/consents/{consentId}/approve", middleware.UserID(http.HandlerFunc(handler.ConsentApprove), userIDOptions))
mux.Handle("POST /me/consents/{consentId}/revoke", middleware.UserID(http.HandlerFunc(handler.ConsentRevoke), userIDOptions))
mux.Handle("GET /me/consents", authManager.Require(http.HandlerFunc(handler.Consents), auth.ScopeConsentsReadSelf))
mux.Handle("GET /me/consents/{consentId}", authManager.Require(http.HandlerFunc(handler.ConsentByID), auth.ScopeConsentsReadSelf))
mux.Handle("POST /me/consents/{consentId}/approve", authManager.Require(http.HandlerFunc(handler.ConsentApprove), auth.ScopeConsentsWriteSelf))
mux.Handle("POST /me/consents/{consentId}/revoke", authManager.Require(http.HandlerFunc(handler.ConsentRevoke), auth.ScopeConsentsWriteSelf))

return nil
}
15 changes: 15 additions & 0 deletions portal/backend/internal/me/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,21 @@ func (s *Service) ForwardRaw(r *http.Request, upstreamMethod, upstreamPath strin
return s.proxy.ForwardRaw(r, upstreamMethod, upstreamPath, queryMutator, body)
}

// OwnedConsentGroupID verifies consent ownership and returns its trusted group ID.
func OwnedConsentGroupID(body []byte, userID string) (string, bool, error) {
var consent consentRetrievalResponse
if err := json.Unmarshal(body, &consent); err != nil {
return "", false, proxy.ErrUpstreamUnavailable
}
userID = strings.TrimSpace(userID)
for _, authorization := range consent.Authorizations {
if authorization.UserID != nil && strings.TrimSpace(*authorization.UserID) == userID {
return strings.TrimSpace(consent.GroupID), true, nil
}
}
return "", false, nil
}

// WriteUpstreamResponse writes an upstream response back to the caller.
func (s *Service) WriteUpstreamResponse(w http.ResponseWriter, resp *proxy.UpstreamResponse) {
_ = s.proxy.WriteUpstreamResponse(w, resp)
Expand Down
5 changes: 3 additions & 2 deletions portal/backend/internal/proxy/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,17 @@ package proxy
import (
"net/http"

"github.com/wso2/openfgc/portal/backend/internal/system/auth"
"github.com/wso2/openfgc/portal/backend/internal/system/config"
)

// Initialize sets up the proxy module and registers routes.
func Initialize(mux *http.ServeMux, cfg config.ProxyConfig) error {
func Initialize(mux *http.ServeMux, cfg config.ProxyConfig, authManager *auth.Manager) error {
handler, err := NewHandler(cfg)
if err != nil {
return err
}

mux.HandleFunc("/api/{path...}", handler.API)
mux.Handle("/api/{path...}", authManager.RequireAPI(http.HandlerFunc(handler.API)))
return nil
}
7 changes: 3 additions & 4 deletions portal/backend/internal/proxy/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"strings"

"github.com/wso2/openfgc/portal/backend/internal/system/config"
systemcontext "github.com/wso2/openfgc/portal/backend/internal/system/context"
"github.com/wso2/openfgc/portal/backend/internal/system/correlation"
)

Expand Down Expand Up @@ -374,13 +375,11 @@ func connectionHeaderNames(headers http.Header) map[string]struct{} {
}

func (s *Service) setTrustedHeaders(incoming *http.Request, outgoing *http.Request, trustedGroupID string) {
if s.cfg.PlaceholderOrgID != "" {
outgoing.Header.Set("org-id", s.cfg.PlaceholderOrgID)
if principal, ok := systemcontext.PrincipalFromContext(incoming.Context()); ok {
outgoing.Header.Set("org-id", principal.OrgID)
}
if trustedGroupID != "" {
outgoing.Header.Set("group-id", trustedGroupID)
} else if s.cfg.PlaceholderGroupID != "" {
outgoing.Header.Set("group-id", s.cfg.PlaceholderGroupID)
}
correlationID := incoming.Header.Get("X-Correlation-ID")
if correlationID == "" {
Expand Down
Loading
Loading