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
2 changes: 2 additions & 0 deletions .github/workflows/portal-frontend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ on:
pull_request:
paths:
- 'portal/frontend/**'
- 'portal/backend/openapi/portal-backend.yaml'
- '.github/workflows/portal-frontend-ci.yml'
push:
branches: [ main ]
paths:
- 'portal/frontend/**'
- 'portal/backend/openapi/portal-backend.yaml'
- '.github/workflows/portal-frontend-ci.yml'

jobs:
Expand Down
9 changes: 6 additions & 3 deletions portal/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ BFF_PROXY__ALLOWED_PASSTHROUGH_METHODS=["GET","POST","PUT","DELETE"]

# Placeholder identity mode for local development (must be false in production)
BFF_PROXY__PLACEHOLDER_MODE_ENABLED=false
BFF_PROXY__PLACEHOLDER_USER_ID=
BFF_PROXY__PLACEHOLDER_ORG_ID=
BFF_PROXY__PLACEHOLDER_USER_ID=user1@example.com
BFF_PROXY__PLACEHOLDER_ORG_ID=ORG-001
BFF_PROXY__PLACEHOLDER_SCOPES=portal:consents:read:self portal:consents:write:self

# OIDC confidential-client authentication
BFF_AUTH__ENABLED=false
Expand All @@ -42,14 +43,16 @@ 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 portal:elements:read portal:elements:write portal:purposes:read portal:purposes:write
BFF_AUTH__SCOPES=openid email profile portal:consents:read:self portal:consents:write:self portal:consents:read:any portal:consents:write:any portal:elements:read portal:elements:write portal:purposes:read portal:purposes:write
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
# Overrides the organization claim when non-empty. Leave empty to use BFF_AUTH__ORG_ID_CLAIM.
BFF_AUTH__ORG_ID_OVERRIDE=ORG-001
BFF_AUTH__REQUIRE_ACCESS_TOKEN_TYPE=false
BFF_AUTH__ACCESS_TOKEN_TYPE_CLAIM=token_type
BFF_AUTH__ACCESS_TOKEN_TYPE_VALUE=access_token
Expand Down
9 changes: 3 additions & 6 deletions portal/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,11 @@ 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_SCOPES` is a space-separated list of portal scope. Must be empty if placeholder mode is disabled
- Empty placeholder scopes are allowed and produce an authenticated principal with no portal permissions

## AI Instructions

This repository uses VS Code Copilot instruction files to keep AI-generated changes aligned with project and organization standards.
This repository uses AGENTS.md 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.
13 changes: 9 additions & 4 deletions portal/backend/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ React frontend ─────► Portal Backend ─────► OpenFGC

| Component | Responsibility |
| --- | --- |
| React frontend | Starts login and logout, sends the readable access-token half, refreshes expired sessions, and renders identity claims for display. It does not make authorization decisions. |
| React frontend | Starts login and logout, sends the readable access-token half, refreshes expired sessions, renders ID-token claims for display, and uses the effective scopes returned by `GET /me` for route and element visibility. These UI decisions improve the user experience; Portal Backend scope enforcement remains the security boundary. |
| Portal Backend | Acts as the OIDC confidential client and protected API boundary. It validates tokens, creates the trusted principal, enforces route policy, and maps allowed requests to OpenFGC. |
| Identity provider | Authenticates users, exposes OIDC discovery and JWKS metadata, and issues signed ID, access, and refresh tokens. |
| OpenFGC | Performs consent operations behind the Portal Backend. It receives identity and organization context derived by the Portal Backend. |
Expand Down Expand Up @@ -87,6 +87,9 @@ The frontend deduplicates concurrent refresh attempts within the current browser
| `GET /health`, `/health/liveness`, `/health/readiness` | Public |
| `GET /auth/login`, `GET /auth/callback`, `POST /auth/refresh` | Public protocol endpoints; login and refresh still require their corresponding split transaction or token material. |
| `POST /auth/logout` | Authenticated |
| `GET /me` | Authenticated without a business-scope requirement; returns the validated user ID, organization ID, and effective portal scopes. |

`GET /me` returns only scopes in the canonical portal registry, preserving registry order. OIDC scopes such as `openid`, `profile`, and `email` are excluded. An authenticated principal without portal permissions receives `200 OK` with an empty `scopes` array.

### Current-user consent routes

Expand Down Expand Up @@ -131,9 +134,11 @@ Configuration is grouped into:
- `BFF_LOG__*`: log level.
- `BFF_CORS__*`: exact browser origins, methods, headers, and credential support.
- `BFF_AUTH__*`: OIDC client, token validation, claims, timeouts, cookies, and token size limits.
- `BFF_PROXY__*`: OpenFGC URL, request and response limits, timeout, passthrough methods, and local placeholder identity.
- `BFF_PROXY__*`: OpenFGC URL, request and response limits, timeout, passthrough methods, and local placeholder identity and scopes.

Production startup requires authentication, HTTPS authentication URLs, and secure cookies. Placeholder identity cannot run in production and cannot be enabled together with OIDC authentication. In permitted local environments, placeholder mode supplies the configured user and organization without an identity provider. `BFF_PROXY__PLACEHOLDER_SCOPES` is a space-separated list of canonical portal scopes granted to that principal. Unknown or duplicate scopes are rejected, and an empty list creates an authenticated principal without portal permissions. Placeholder identity and scope values must be empty when placeholder mode is disabled.

Production startup requires authentication, HTTPS authentication URLs, and secure cookies. Placeholder identity cannot run in production and cannot be enabled together with OIDC authentication. In permitted local environments, placeholder mode supplies a configured user and organization with all portal scopes for development without an identity provider.
For authenticated deployments, the organization normally comes from the access-token claim named by `BFF_AUTH__ORG_ID_CLAIM`. Set `BFF_AUTH__ORG_ID_OVERRIDE` to force a fixed organization ID instead; a non-empty override takes precedence and the token does not need the organization claim. Leave the override empty to keep claim-based resolution.

### Local topology

Expand All @@ -150,6 +155,6 @@ The backend Docker Compose file builds and publishes the Portal Backend on port

## Testing

Backend unit and integration tests cover configuration guardrails, OIDC transactions, token splitting and validation, cookie handling, authorization scopes, CORS, current-user ownership, proxy mappings, header sanitization, limits, and upstream failures. Frontend tests cover the auth client, refresh retry behavior, profile rendering, API integration, and production CSP generation.
Backend unit and integration tests cover configuration guardrails, OIDC transactions, token splitting and validation, cookie handling, the `GET /me` session response, placeholder-scope filtering, authorization scopes, CORS, current-user ownership, proxy mappings, header sanitization, limits, and upstream failures. Frontend tests cover the `/me` session gate, scope-based routes and elements, sidebar visibility, authorization redirects, auth-client refresh behavior, profile rendering, API integration, and production CSP generation. A frontend test parses the backend OpenAPI document and detects missing, extra, empty, or duplicate portal scopes relative to the centralized `PORTAL_SCOPES` registry.

Use the commands in the [backend README](../README.md) and [frontend README](../../frontend/README.md) to run their respective checks. Changes to routes or scopes must also update and validate the [OpenAPI specification](../openapi/portal-backend.yaml).
25 changes: 25 additions & 0 deletions portal/backend/internal/me/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"strings"

"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"
systemcontext "github.com/wso2/openfgc/portal/backend/internal/system/context"
)
Expand All @@ -46,6 +47,12 @@ type errorResponse struct {
Message string `json:"message"`
}

type currentUserResponse struct {
UserID string `json:"userId"`
OrganizationID string `json:"organizationId"`
Scopes []string `json:"scopes"`
}

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}$`)

Expand All @@ -58,6 +65,24 @@ func NewHandler(cfg config.ProxyConfig) (*Handler, error) {
return &Handler{svc: svc, cfg: cfg}, nil
}

// CurrentUser handles GET /me.
func (h *Handler) CurrentUser(w http.ResponseWriter, r *http.Request) {
principal, ok := systemcontext.PrincipalFromContext(r.Context())
if !ok {
writeJSONError(w, http.StatusUnauthorized, "UNAUTHORIZED", "authentication required")
return
}

w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(currentUserResponse{
UserID: principal.UserID,
OrganizationID: principal.OrgID,
Scopes: auth.GrantedPortalScopes(principal.Scopes),
})
}

// Consents handles GET /me/consents.
func (h *Handler) Consents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Expand Down
74 changes: 74 additions & 0 deletions portal/backend/internal/me/handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* 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"
"net/http"
"net/http/httptest"
"reflect"
"testing"

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

func TestCurrentUserReturnsCanonicalPortalScopes(t *testing.T) {
principal := systemcontext.Principal{
UserID: "user-1",
OrgID: "org-1",
Scopes: map[string]struct{}{
"openid": {},
auth.ScopePurposesWrite: {},
auth.ScopeConsentsReadSelf: {},
},
}
req := httptest.NewRequest(http.MethodGet, "/me", nil)
req = req.WithContext(systemcontext.WithPrincipal(req.Context(), principal))
recorder := httptest.NewRecorder()

(&Handler{}).CurrentUser(recorder, req)

if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
if got := recorder.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q, want no-store", got)
}
var response currentUserResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.UserID != "user-1" || response.OrganizationID != "org-1" {
t.Fatalf("unexpected identity: %+v", response)
}
wantScopes := []string{auth.ScopeConsentsReadSelf, auth.ScopePurposesWrite}
if !reflect.DeepEqual(response.Scopes, wantScopes) {
t.Fatalf("scopes = %v, want %v", response.Scopes, wantScopes)
}
}

func TestCurrentUserRequiresPrincipal(t *testing.T) {
recorder := httptest.NewRecorder()
(&Handler{}).CurrentUser(recorder, httptest.NewRequest(http.MethodGet, "/me", nil))

if recorder.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusUnauthorized)
}
}
1 change: 1 addition & 0 deletions portal/backend/internal/me/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func Initialize(mux *http.ServeMux, cfg config.Config, authManager *auth.Manager
return err
}

mux.Handle("GET /me", authManager.Require(http.HandlerFunc(handler.CurrentUser)))
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))
Expand Down
6 changes: 5 additions & 1 deletion portal/backend/internal/system/auth/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ func (m *Manager) Login(w http.ResponseWriter, r *http.Request) {
return
}
m.setLoginTransactionCookies(w, state, verifier)
authorizationURL := m.oauthConfig.AuthCodeURL(state, pkceAuthorizationOption(verifier))
authorizationURL := m.oauthConfig.AuthCodeURL(
state,
pkceAuthorizationOption(verifier),
oauth2.SetAuthURLParam("audience", m.cfg.ResourceAudience),
)
http.Redirect(w, r, authorizationURL, http.StatusFound)
}

Expand Down
3 changes: 3 additions & 0 deletions portal/backend/internal/system/auth/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ func TestOIDCLoginCallbackRefreshAndLogout(t *testing.T) {
}
state := authorizationURL.Query().Get("state")
expectedPKCEChallenge = authorizationURL.Query().Get("code_challenge")
if audience := authorizationURL.Query().Get("audience"); audience != cfg.ResourceAudience {
t.Fatalf("unexpected authorization audience: %q", audience)
}
if state == "" || expectedPKCEChallenge == "" || authorizationURL.Query().Get("code_challenge_method") != "S256" {
t.Fatalf("login redirect missing state or S256 PKCE parameters: %s", authorizationURL.RawQuery)
}
Expand Down
33 changes: 30 additions & 3 deletions portal/backend/internal/system/auth/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
Expand Down Expand Up @@ -53,6 +54,11 @@ type Manager struct {
func NewManager(ctx context.Context, cfg config.AuthConfig, proxyCfg config.ProxyConfig, log *slog.Logger) (*Manager, error) {
m := &Manager{cfg: cfg, proxyCfg: proxyCfg, log: log}
if !cfg.Enabled {
if proxyCfg.PlaceholderModeEnabled {
if err := validatePlaceholderScopes(proxyCfg.PlaceholderScopes); err != nil {
return nil, err
}
}
return m, nil
}
if err := validateConfiguredScopes(cfg.Scopes); err != nil {
Expand Down Expand Up @@ -110,6 +116,24 @@ func validateConfiguredScopesWithPrefix(configured []string, prefix string, port
return nil
}

func validatePlaceholderScopes(configured []string) error {
canonical := make(map[string]struct{}, len(AllPortalScopes))
for _, scope := range AllPortalScopes {
canonical[scope] = struct{}{}
}
seen := make(map[string]struct{}, len(configured))
for _, scope := range configured {
if _, ok := canonical[scope]; !ok {
return fmt.Errorf("proxy placeholder scopes contain unknown portal scope %q", scope)
}
if _, duplicate := seen[scope]; duplicate {
return fmt.Errorf("proxy placeholder scopes contain duplicate scope %q", scope)
}
seen[scope] = struct{}{}
}
return nil
}

// RegisterRoutes registers browser-facing authentication endpoints.
func (m *Manager) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /auth/login", m.Login)
Expand Down Expand Up @@ -164,8 +188,8 @@ func (m *Manager) authenticate(r *http.Request) (systemcontext.Principal, error)
if userID == "" || orgID == "" {
return systemcontext.Principal{}, errInvalidCredentials
}
scopes := make(map[string]struct{}, len(AllPortalScopes))
for _, scope := range AllPortalScopes {
scopes := make(map[string]struct{}, len(m.proxyCfg.PlaceholderScopes))
for _, scope := range m.proxyCfg.PlaceholderScopes {
scopes[scope] = struct{}{}
}
return systemcontext.Principal{UserID: userID, OrgID: orgID, Scopes: scopes}, nil
Expand Down Expand Up @@ -197,7 +221,10 @@ func (m *Manager) validateAccessToken(ctx context.Context, raw string) (systemco
return systemcontext.Principal{}, time.Time{}, errInvalidCredentials
}
subject := strings.TrimSpace(verified.Subject)
orgID, err := stringClaim(claims, m.cfg.OrgIDClaim)
orgID := strings.TrimSpace(m.cfg.OrgIDOverride)
if orgID == "" {
orgID, err = stringClaim(claims, m.cfg.OrgIDClaim)
}
if err != nil || subject == "" || orgID == "" {
return systemcontext.Principal{}, time.Time{}, errInvalidCredentials
}
Expand Down
Loading
Loading