Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 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: 1 addition & 1 deletion consent-server/internal/consent/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1020,7 +1020,7 @@ func (s *consentService) ExpireConsent(ctx context.Context, consent *model.Conse
sysExpiredAuthStatus := string(config.Get().Consent.GetSystemExpiredAuthStatus())
currentTime := utils.GetCurrentTimeMillis()

reason := "Consent expired based on expirationTime"
reason := "Consent expired based on expiration time"
actionBy := "SYSTEM"
// For Re-check after locking as expiry can be triggered by multiple request paths and the cron job.
shouldExpire := true
Expand Down
47 changes: 47 additions & 0 deletions portal/README.md
Original file line number Diff line number Diff line change
@@ -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.
-->

# OpenFGC Portal

OpenFGC Portal is a user interface for managing consents through the OpenFGC Consent Management API.

## Architecture

The React frontend calls the Go Portal Backend, which authenticates users through an OpenID Connect identity provider, enforces authorization, and forwards allowlisted requests to OpenFGC.

```text
React frontend ──► Portal Backend ──► OpenFGC
│
└────────► OIDC identity provider
```

The browser never stores the OIDC client secret or calls OpenFGC directly.

## Quickstart

1. [Start the OpenFGC consent server](../README.md#quick-start).
2. [Configure and start the Portal Backend](backend/README.md#quickstart).
3. [Configure and start the Portal Frontend](frontend/README.md#quickstart).
4. Open `http://localhost:5173`.

## Components

- [Portal Frontend development and configuration](frontend/README.md)
- [Portal Backend development and configuration](backend/README.md)

For authentication flows, authorization, routing, security, and deployment details, see the [portal architecture and operations guide](backend/docs/README.md).
2 changes: 1 addition & 1 deletion portal/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ 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__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__RESOURCE_AUDIENCE=
BFF_AUTH__ALLOWED_SIGNING_ALGORITHMS=RS256
BFF_AUTH__HTTP_TIMEOUT=5s
Expand Down
26 changes: 23 additions & 3 deletions portal/backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,29 @@
* under the License.
-->

# OpenFGC Portal Backend (BFF)
# OpenFGC Portal Backend

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`.
The OpenFGC Portal Backend handles portal-facing authentication flows and securely proxies allowlisted API requests from `portal/frontend` to OpenFGC.

## Quickstart

Prerequisites:

- Go 1.26.3
- [Task](https://taskfile.dev/installation/)
- A running OpenFGC server

From `portal/backend`:

1. Copy `.env.example` to `.env` and set `BFF_PROXY__OPENFGC_API_URL` for your OpenFGC server.
2. To access protected routes locally without an identity provider, enable placeholder mode and set non-empty placeholder user and organization IDs in `.env`.
3. Start the Portal Backend:

```shell
task run:env
```

The server listens on `http://localhost:8080` by default. Verify it with `GET /health`.

## Commands

Expand Down Expand Up @@ -63,7 +83,7 @@ Portal-facing user endpoints:

- `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}/approve` -> the Portal Backend 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`

Proxy hardening:
Expand Down
155 changes: 155 additions & 0 deletions portal/backend/docs/README.md
Original file line number Diff line number Diff line change
@@ -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.
-->

# Portal Architecture and Operations

This guide describes the implemented OpenFGC Portal architecture. For commands and component-specific setup, use the [backend README](../README.md) and [frontend README](../../frontend/README.md). The complete HTTP contract is defined in the [OpenAPI specification](../openapi/portal-backend.yaml).

## Architecture

```text
React frontend ─────► Portal Backend ─────► OpenFGC
│
└──── OIDC discovery and flows ────► Identity provider
```

### Responsibilities

| 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. |
| 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. |

The Portal Backend is stateless: login correlation is held in short-lived browser cookies, and authenticated request context is reconstructed from validated tokens. OpenFGC should not be exposed directly to browsers or the public network.

## Authentication

Authentication is enabled with `BFF_AUTH__ENABLED=true`. The Portal Backend performs OIDC discovery during startup and uses Authorization Code flow as a confidential client.

### Login

1. The frontend navigates to `GET /auth/login`.
2. The Portal Backend generates random OAuth state and a PKCE verifier, stores each in a short-lived `HttpOnly` cookie, and redirects to the identity provider with an S256 code challenge.
3. The identity provider redirects to `GET /auth/callback` with one authorization code and state value.
4. The Portal Backend consumes the transaction cookies, compares state, validates the verifier, and exchanges the code using the client credentials and PKCE verifier.
5. The returned access token is validated for the configured API audience; the ID token is validated for the OIDC client. Both use issuer discovery and JWKS verification.
6. The Portal Backend issues split-token cookies and redirects to `BFF_AUTH__PORTAL_URL`.

Invalid, incomplete, duplicate, or replayed callback data is rejected with a generic login failure. Transaction cookies are cleared before the code exchange so they cannot be reused.

### Split-token session

Tokens are divided at a deterministic midpoint and constrained by configured per-part and reconstructed-token size limits.

| Token | JavaScript-readable portion | `HttpOnly` portion | Use |
| --- | --- | --- | --- |
| Access token | Part 1 cookie | Part 2 cookie | Part 1 is sent as `Authorization: Bearer <part-1>`; the browser sends part 2 with credentials. |
| Refresh token | Part 1 cookie | Part 2 cookie | Part 1 is submitted in the form body to `/auth/refresh`; the browser sends part 2 with credentials. |
| ID token | Parts 1 and 2 | None | The frontend reconstructs and decodes it for profile display only. |

For each protected request, the Portal Backend requires exactly one partial Bearer value and exactly one access-token part-2 cookie. It reconstructs the JWT, verifies its signature, issuer, resource audience, expiry, not-before claim when present, subject, organization claim, and configured token type when enabled. The validated identity is normalized into a [`Principal`](../internal/system/context/user.go) containing `UserID`, `OrgID`, and `Scopes`, then stored in request context. Downstream authorization and proxy code use this principal rather than raw token claims.

The split-token design prevents JavaScript from reading a complete access or refresh token, but it does not eliminate the impact of same-origin script injection. The frontend therefore emits a restrictive Content Security Policy and performs production security checks as part of its build workflow.

### Refresh and request retry

`POST /auth/refresh` accepts only `application/x-www-form-urlencoded` with one `refresh_token` field containing refresh-token part 1. Cookie-only refresh is rejected. The Portal Backend reconstructs the refresh token, exchanges it at the identity provider, validates the returned access token and optional ID token, and returns `204 No Content` after replacing the applicable cookies. A rotated refresh token replaces both refresh cookies; otherwise the existing refresh cookies remain in use. Identity-provider exchange or returned-token validation failures clear the token cookies.

The frontend deduplicates concurrent refresh attempts within the current browser runtime. After a protected request returns `401`, it performs at most one refresh and retries that request once. A failed refresh starts login. A `403` is treated as an authorization failure and does not trigger refresh.

### Logout

`POST /auth/logout` is protected by the same split access-token authentication as other protected routes. It clears all local token cookies and returns a logout URL. When OIDC discovery provides an end-session endpoint, the Portal Backend uses it with the configured post-logout redirect and includes the reconstructed ID token only if it validates successfully. The frontend navigates only to an explicitly allowed HTTP or HTTPS origin.

## Routes and Authorization

### Public and authentication routes

| Routes | Access |
| --- | --- |
| `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 |

### Current-user consent routes

| Routes | Required scope |
| --- | --- |
| `GET /me/consents`, `GET /me/consents/{consentId}` | `portal:consents:read:self` |
| `POST /me/consents/{consentId}/approve` | `portal:consents:write:self` |
| `POST /me/consents/{consentId}/reject` | `portal:consents:write:self` |
| `POST /me/consents/{consentId}/revoke` | `portal:consents:write:self` |

The Portal Backend replaces browser-supplied user filters with the validated subject. Object routes fetch the consent and verify ownership before returning details or applying a mutation. Approval and rejection payloads are built from the current consent snapshot, and trusted `actionBy` and `group-id` values are derived by the Portal Backend rather than accepted from the browser.

### Controlled API passthrough

`/api/*` is a deny-by-default passthrough to OpenFGC `/api/v1/*`. Unknown paths return `404`; unsupported methods on known paths return `405`. The allowlist covers consent, consent-authorization, consent-element, and consent-purpose operations documented in OpenAPI. The canonical portal scope names and method/path-to-scope policies are defined in [`scopes.go`](../internal/system/auth/scopes.go).

| Operation family | Read scope | Write scope |
| --- | --- | --- |
| Consents and authorizations | `portal:consents:read:any` | `portal:consents:write:any` |
| Consent elements | `portal:elements:read` | `portal:elements:write` |
| Consent purposes | `portal:purposes:read` | `portal:purposes:write` |

Authentication failures return `401` with `WWW-Authenticate: Bearer`. An authenticated principal without the required scope receives `403` without a bearer challenge.

### Trusted upstream context

Before forwarding, the Portal Backend removes hop-by-hop headers and browser-supplied identity headers. It sets `org-id` from the validated principal, adds a consent-bound `group-id` only for self-service mutations that require it, and propagates or generates `X-Correlation-ID`. The raw access token is not forwarded to OpenFGC.

## Configuration and Runtime

The Portal Backend loads configuration with the following precedence:

```text
defaults < optional YAML file < BFF_ environment variables
```

Set the optional file with `BFF_CONFIG_FILE`. `BFF_AUTH__CLIENT_SECRET` is intentionally read only from the process environment and is not loaded from YAML. The complete development contract and defaults are in [`.env.example`](../.env.example).

Configuration is grouped into:

- `BFF_SERVER__*`: listener and server timeouts.
- `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.

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.

### Local topology

| Service | Typical local address |
| --- | --- |
| React development server | `http://localhost:5173` |
| Portal Backend | `http://localhost:8080` |
| OpenFGC | `http://localhost:8060` when configured through `.env.example` |
| Identity provider | Deployment-specific `BFF_AUTH__ISSUER_URL` |

When the frontend and Portal Backend use different origins, requests include credentials and the frontend origin must be explicitly listed in `BFF_CORS__ALLOWED_ORIGINS`. Credentialed CORS does not allow a wildcard origin. Matching frontend and backend cookie-name configuration is required.

The backend Docker Compose file builds and publishes the Portal Backend on port 8080. OpenFGC and the identity provider must be started or supplied separately.

## 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.

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).
78 changes: 77 additions & 1 deletion portal/backend/internal/me/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import (
systemcontext "github.com/wso2/openfgc/portal/backend/internal/system/context"
)

var errInvalidConsentState = errors.New("invalid consent state")

// Handler serves /me route groups.
type Handler struct {
svc *Service
Expand Down Expand Up @@ -96,7 +98,7 @@ func (h *Handler) ConsentByID(w http.ResponseWriter, r *http.Request) {

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")
q.Set("includeStatusHistory", "true")
}, nil)
if err != nil {
writeProxyError(w, err)
Expand Down Expand Up @@ -180,6 +182,68 @@ func (h *Handler) ConsentApprove(w http.ResponseWriter, r *http.Request) {
}
}

// ConsentReject handles POST /me/consents/{consentId}/reject.
func (h *Handler) ConsentReject(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
}
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
}
if err := validateEmptyObjectPayload(body); 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), func(q url.Values) {
q.Set("details", "true")
q.Del("includeStatusHistory")
}, 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
}

payload, trustedGroupID, err := h.svc.BuildRejectionUpdatePayload(baseResp.Body, userID)
if err != nil {
if errors.Is(err, errInvalidConsentState) {
writeJSONError(w, http.StatusConflict, "INVALID_CONSENT_STATE", "only created consents can be rejected")
return
}
writeProxyError(w, err)
return
}
if err := h.svc.ForwardWithGroupID(w, r, http.MethodPut, "/api/v1/consents/"+url.PathEscape(consentID), nil, payload, trustedGroupID); err != nil {
writeProxyError(w, err)
}
}

// ConsentRevoke handles POST /me/consents/{consentId}/revoke.
func (h *Handler) ConsentRevoke(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Expand Down Expand Up @@ -262,6 +326,18 @@ func writeBodyReadError(w http.ResponseWriter, err error) {
writeJSONError(w, http.StatusBadRequest, "INVALID_REQUEST_BODY", "invalid request body")
}

func validateEmptyObjectPayload(body []byte) error {
if strings.TrimSpace(string(body)) == "" {
return nil
}

var payload map[string]json.RawMessage
if err := json.Unmarshal(body, &payload); err != nil || payload == nil || len(payload) > 0 {
return errors.New("expected an empty JSON object")
}
return nil
}

func (h *Handler) resolveUserID(w http.ResponseWriter, r *http.Request) (string, bool) {
principal, ok := systemcontext.PrincipalFromContext(r.Context())
if !ok {
Expand Down
Loading
Loading