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
4 changes: 2 additions & 2 deletions FEATURE_FLAGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ The `/api/v1/auth/config` response includes a `features` object:
```json
{
"authDisabled": false,
"issuer": "https://...",
"clientId": "openshell-dashboard",
"adminRole": "admin",
"logoutUrl": "/oauth2/sign_out",
"features": {
"terminal": true,
"fileTransfer": true,
Expand Down
67 changes: 51 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ make dev

Open http://localhost:3000, click **Continue as developer**, and you're in.

### Local dev with OIDC (full auth stack)
### Local dev with an OIDC gateway (full stack)

To test with real OIDC authentication against a local Keycloak and OpenShell gateway, use the included dev environment script. This sets up self-signed TLS, a Keycloak instance in Podman, and builds the gateway from source.
To develop against a gateway that has real OIDC configured (Keycloak), use the included dev environment script. This sets up self-signed TLS, a Keycloak instance in Podman, and builds the gateway from source. The dashboard itself runs in dev mode (the gateway allows unauthenticated calls locally); Keycloak mints real JWTs for exercising the Bearer relay path with curl or the OpenShell CLI. To test the full browser-auth flow, put oauth2-proxy in front of the BFF (see Auth below).

**Additional prereqs:** Podman (with `podman machine start` on macOS), Rust toolchain (`cargo`), and the [OpenShell](https://github.com/NVIDIA/OpenShell) repo cloned locally.

Expand Down Expand Up @@ -77,26 +77,61 @@ All flags have env var fallbacks:
|------|---------|---------|-------------|
| `-port` | `PORT` | `8080` | BFF listen port |
| `-gateway-url` | `OPENSHELL_GATEWAY_URL` | `localhost:50051` | Gateway gRPC endpoint (`grpcs://` prefix for TLS) |
| | `OIDC_ISSUER` |: | OIDC issuer URL (enables standalone mode) |
| | `OIDC_CLIENT_ID` |: | OIDC client ID |
| | `OIDC_CLIENT_SECRET` |: | Optional client secret. Without it the BFF is a public client (PKCE only) |
| | `OIDC_SCOPES` | `openid profile email groups` | Requested scopes. `groups` is Keycloak/Dex-shaped; Entra ID rejects it — override for other IdPs |
| | `SESSION_SECRET` |: | Key for encrypted session cookies (standalone OIDC). **Required** unless `DEPLOYMENT_CONTEXT=dev` — the BFF fails closed without it |
| | `DEPLOYMENT_CONTEXT` | `standalone` | `dev` permits an ephemeral session secret for local use |
| `-static-dir` | `STATIC_DIR` |: | Serve built frontend from this directory |
| `-auth-disabled` | `AUTH_DISABLED` | `false` | Skip auth: **dev only** |
| `-auth-token-header` | `AUTH_TOKEN_HEADER` | `x-forwarded-access-token` | Header the auth proxy injects the bearer into |
| `-auth-user-header` | `AUTH_USER_HEADER` | `x-auth-request-user` | Header the auth proxy injects the username into |
| `-admin-role` | `ADMIN_ROLE` | `admin` | Role name the frontend treats as platform admin (display gating only) |
| `-logout-url` | `LOGOUT_URL` | `/oauth2/sign_out` | Auth proxy sign-out URL the frontend redirects to on logout |
| `-gateway-ca-cert` | `GATEWAY_CA_CERT` |: | Path to CA cert for self-signed gateway TLS |
| `-allowed-origins` | `ALLOWED_ORIGINS` |: | Comma-separated extra CORS/WebSocket origins (same-origin is always allowed) |

## Auth

OIDC only (no mTLS, no OpenShift OAuth). Three modes, one middleware:

- **Standalone OIDC** (`OIDC_ISSUER` + `OIDC_CLIENT_ID` set): the frontend runs an Authorization Code + PKCE flow against your IdP; the BFF exchanges the code server-side and seals the tokens into an encrypted, HttpOnly session cookie (`__Host-openshell-session`). The browser never sees a token, and the cookie authenticates everything — REST calls and the terminal's WebSocket handshake alike. Expired sessions are refreshed against the IdP transparently, server-side (requires the IdP to issue a refresh token — some providers need `offline_access` added to `OIDC_SCOPES`), up to a 12h absolute lifetime. Any spec-compliant OIDC provider works, but defaults are Keycloak/Dex-shaped: adjust `OIDC_SCOPES` for IdPs without a `groups` scope (e.g. Entra ID), register the BFF as a confidential client and set `OIDC_CLIENT_SECRET` where possible, and ensure the **gateway's configured audience matches `OIDC_CLIENT_ID`** — the BFF forwards the ID token as the bearer, and the gateway validates its `aud` against the client ID.
- **Federated** (behind oauth2-proxy / kube-auth-proxy): the proxy injects the user's token as `x-forwarded-access-token`; the BFF forwards it.
- **Dev** (`AUTH_DISABLED=true`): no auth, synthetic dev-user, no tokens forwarded.

The BFF never validates JWTs — it forwards the bearer to the gateway on every gRPC call, and the gateway makes all RBAC decisions. See `docs/adrs/0010-cookie-session-standalone-auth.md` for the full design.
**The BFF is a token relay.** It runs no OIDC flows, holds no sessions, and
never validates tokens. Browser authentication is owned by an auth proxy in
front of it; the BFF reads the bearer the proxy injects
(`x-forwarded-access-token`, configurable) — or an explicit `Authorization:
Bearer` from API clients — and forwards it to the gateway on every gRPC
call. The gateway validates the JWT against its own OIDC JWKS and makes all
RBAC decisions.

- **Production / standalone with auth:** run [oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/)
(or kube-auth-proxy on OpenShift) in front of the BFF, registered as an
OIDC client with the **same IdP the gateway trusts**, with an audience the
gateway accepts. oauth2-proxy handles login, cookie sessions, refresh, and
sign-out (`/oauth2/sign_out` — the BFF's default `LOGOUT_URL`), and it
authenticates WebSocket upgrades (the terminal) like any other request.
The secure-agent-workspace validated pattern ships exactly this setup.
**Deployment requirement:** the BFF must only be reachable through the
proxy — anything that can reach the BFF directly can present any header.

A verified sidecar configuration (Dex as IdP, gateway audience =
`client_id`):

```
--provider=oidc
--oidc-issuer-url=https://<idp> # same issuer the gateway trusts
--client-id=openshell-dashboard # must match the gateway's audience
--redirect-url=https://<dashboard-host>/oauth2/callback
--upstream=http://127.0.0.1:8080/ # the BFF
--http-address=0.0.0.0:4180 # point the Service/Route here
--scope=openid profile email groups
--pass-authorization-header=true # forwards the ID token as the bearer
--pass-user-headers=true # then set AUTH_USER_HEADER=x-forwarded-user
--email-domain=*
--reverse-proxy=true
--insecure-oidc-allow-unverified-email # needed for IdPs that map a username
# into the email claim without
# email_verified (e.g. Dex's
# OpenShift connector)
```

Note the client must be **confidential** (oauth2-proxy requires a client
secret) — a PKCE-only public client registration is not enough.
- **Dev** (`AUTH_DISABLED=true`): no auth, synthetic dev-user, no tokens
forwarded. `make dev-full` runs the gateway with unauthenticated calls
allowed; Keycloak still mints real JWTs for exercising the Bearer relay
path with curl or the CLI.

## Make targets

Expand Down
76 changes: 11 additions & 65 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@
// (optionally) the built static assets, talking to the OpenShell gateway over
// gRPC with per-request bearer token forwarding.
//
// Authentication is delegated to an external auth proxy (oauth2-proxy,
// kube-rbac-proxy, etc.) which injects the bearer token as an HTTP header.
// The BFF reads that header and forwards the token to the gateway.
// The BFF is a token relay (ADR 0014): authentication is owned by an external
// auth proxy (oauth2-proxy, kube-auth-proxy, ...) which injects the user's
// bearer token as an HTTP header. The BFF reads that header — or an explicit
// Authorization: Bearer from API clients — and forwards the token to the
// gateway, which validates it against its own OIDC JWKS. The BFF never runs
// OIDC flows, never holds sessions, and never validates tokens.
package main

import (
"crypto/rand"
"flag"
"log/slog"
"net/http"
"os"
"strings"
"time"

"github.com/Gkrumbach07/openshell-dashboard/backend/internal/api"
Expand All @@ -40,11 +41,10 @@ func main() {
gatewayCACert = flag.String("gateway-ca-cert", envOr("GATEWAY_CA_CERT", ""), "path to CA cert for gateway TLS (env GATEWAY_CA_CERT)")
staticDir = flag.String("static-dir", envOr("STATIC_DIR", ""), "frontend static assets directory (env STATIC_DIR)")
authDisabled = flag.Bool("auth-disabled", envOr("AUTH_DISABLED", "false") == "true", "skip auth — dev only (env AUTH_DISABLED)")
origins = flag.String("allowed-origins", envOr("ALLOWED_ORIGINS", ""), "comma-separated CORS origins (env ALLOWED_ORIGINS)")
tokenHeader = flag.String("auth-token-header", envOr("AUTH_TOKEN_HEADER", "x-forwarded-access-token"), "header injected by auth proxy containing the bearer token (env AUTH_TOKEN_HEADER)")
userHeader = flag.String("auth-user-header", envOr("AUTH_USER_HEADER", "x-auth-request-user"), "header injected by auth proxy containing the username (env AUTH_USER_HEADER)")
adminRole = flag.String("admin-role", envOr("ADMIN_ROLE", "admin"), "role name that grants platform admin access (env ADMIN_ROLE)")
logoutURL = flag.String("logout-url", envOr("LOGOUT_URL", "/oauth2/sign_out"), "URL to redirect to on logout (env LOGOUT_URL)")
logoutURL = flag.String("logout-url", envOr("LOGOUT_URL", "/oauth2/sign_out"), "auth proxy sign-out URL to redirect to on logout (env LOGOUT_URL)")
)
flag.Parse()

Expand All @@ -58,54 +58,15 @@ func main() {
slog.Warn("AUTH_DISABLED=true — authentication is OFF; never use this outside local development")
}

// Federated mode = an auth proxy fronts the BFF (no in-app OIDC issuer).
// Only then is the x-forwarded-access-token header trustworthy; in
// standalone mode any client could forge it to bypass the session cookie.
federated := os.Getenv("OIDC_ISSUER") == "" && !*authDisabled

authMiddleware := auth.New(auth.Config{
Disabled: *authDisabled,
TokenHeader: *tokenHeader,
UserHeader: *userHeader,
TrustProxyHeader: federated,
Disabled: *authDisabled,
TokenHeader: *tokenHeader,
UserHeader: *userHeader,
})

// Cookie sessions are only used in standalone OIDC mode. SESSION_SECRET
// must be set explicitly outside dev: an auto-generated secret means every
// restart invalidates all sessions, and each replica gets a different key
// so cookies sealed on one pod fail to decrypt on another — surfacing as
// intermittent random logouts. Fail closed unless DEPLOYMENT_CONTEXT=dev.
var sessionCodec *auth.SessionCodec
if issuer := os.Getenv("OIDC_ISSUER"); issuer != "" && !*authDisabled {
secret := os.Getenv("SESSION_SECRET")
if secret == "" {
if os.Getenv("DEPLOYMENT_CONTEXT") != "dev" {
slog.Error("SESSION_SECRET is required when OIDC is configured (set DEPLOYMENT_CONTEXT=dev to allow an ephemeral secret for local development)")
os.Exit(1)
}
generated := make([]byte, 32)
if _, err := rand.Read(generated); err != nil {
slog.Error("failed to generate a session secret", "error", err)
os.Exit(1)
}
secret = string(generated)
slog.Warn("SESSION_SECRET not set — using an ephemeral dev secret; sessions won't survive restarts")
}
codec, err := auth.NewSessionCodec([]byte(secret))
if err != nil {
slog.Error("session codec setup failed", "error", err)
os.Exit(1)
}
sessionCodec = codec
}

authCfg := api.AuthConfigResponse{
AuthDisabled: *authDisabled,
Issuer: envOr("OIDC_ISSUER", ""),
ClientID: envOr("OIDC_CLIENT_ID", ""),
Scopes: envOr("OIDC_SCOPES", "openid profile email groups"),
AdminRole: *adminRole,
UserRole: envOr("OIDC_USER_ROLE", ""),
LogoutURL: *logoutURL,
Features: api.FeatureFlags{
Terminal: envOr("FEATURE_TERMINAL", "true") == "true",
Expand All @@ -115,9 +76,6 @@ func main() {
CredentialRefresh: envOr("FEATURE_CREDENTIAL_REFRESH", "true") == "true",
Services: envOr("FEATURE_SERVICES", "true") == "true",
DraftPolicy: envOr("FEATURE_DRAFT_POLICY", "true") == "true",
DeploymentContext: envOr("DEPLOYMENT_CONTEXT", "standalone"),
WorkspaceBinding: envOr("FEATURE_WORKSPACE_BINDING", "false") == "true",
ResourceLinks: envOr("FEATURE_RESOURCE_LINKS", "false") == "true",
},
}

Expand All @@ -128,19 +86,7 @@ func main() {
}
defer gatewayClient.Close()

var allowedOrigins []string
for _, o := range strings.Split(*origins, ",") {
if trimmed := strings.TrimSpace(o); trimmed != "" {
allowedOrigins = append(allowedOrigins, trimmed)
}
}

app := api.NewApp(gatewayClient, authMiddleware, sessionCodec, *staticDir, allowedOrigins, authCfg)
// Optional confidential-client secret for the IdP token endpoint (env
// only — never a flag, so it can't leak into process listings).
if secret := os.Getenv("OIDC_CLIENT_SECRET"); secret != "" {
app.SetOIDCClientSecret(secret)
}
app := api.NewApp(gatewayClient, authMiddleware, *staticDir, authCfg)

addr := ":" + *port
slog.Info("openshell-dashboard BFF listening",
Expand Down
105 changes: 13 additions & 92 deletions backend/internal/api/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,48 +16,30 @@ import (

// App wires the gateway client, auth middleware, and REST routes.
type App struct { //nolint:govet // fieldalignment: readability over padding
gateway gateway.Interface
auth *auth.Middleware
sessions *auth.SessionCodec
gateway gateway.Interface
auth *auth.Middleware
// authConfig is serialized to the browser via GET /auth/config — never
// put secrets in it.
authConfig AuthConfigResponse
// oidcClientSecret authenticates the BFF to the IdP token endpoint as a
// confidential client (client_secret_post). Empty = public client (PKCE
// only). Kept outside authConfig so it cannot leak to the frontend.
oidcClientSecret string
staticDir string
allowedOrigins []string
maxUploadSize int64
execTimeout uint32
authConfig AuthConfigResponse
staticDir string
maxUploadSize int64
execTimeout uint32
}

// SetOIDCClientSecret configures confidential-client authentication for
// token-endpoint calls. Optional; without it the BFF acts as a public client.
func (app *App) SetOIDCClientSecret(secret string) {
app.oidcClientSecret = secret
}

// NewApp builds the application. sessions may be nil, which disables cookie
// sessions (federated and dev deployments don't need them).
func NewApp(gw gateway.Interface, authMiddleware *auth.Middleware, sessions *auth.SessionCodec, staticDir string, allowedOrigins []string, authCfg AuthConfigResponse) *App {
// NewApp builds the application.
func NewApp(gw gateway.Interface, authMiddleware *auth.Middleware, staticDir string, authCfg AuthConfigResponse) *App {
app := &App{
gateway: gw,
auth: authMiddleware,
sessions: sessions,
authConfig: authCfg,
staticDir: staticDir,
allowedOrigins: allowedOrigins,
gateway: gw,
auth: authMiddleware,
authConfig: authCfg,
staticDir: staticDir,
}
if app.maxUploadSize == 0 {
app.maxUploadSize = 64 << 20 // 64 MiB
}
if app.execTimeout == 0 {
app.execTimeout = 30
}
if sessions != nil && authMiddleware != nil {
authMiddleware.SetSessionAuthenticator(&sessionManager{codec: sessions, app: app})
}
return app
}

Expand All @@ -67,25 +49,17 @@ func (app *App) Routes() http.Handler {
r.Use(chimiddleware.RequestID)
r.Use(chimiddleware.Logger)
r.Use(chimiddleware.Recoverer)
r.Use(app.corsMiddleware)
r.Use(app.csrfMiddleware)

r.Route("/api/v1", func(r chi.Router) {
// Public: frontend bootstrap config and OIDC endpoints, no token needed.
// Public: frontend bootstrap config, no token needed.
r.Get("/auth/config", app.GetAuthConfig)
r.Get("/auth/discovery", app.GetOIDCDiscovery)
r.Post("/auth/token-exchange", app.TokenExchange)
// Logout is POST: it clears the session (state-changing), so it must
// pass the CSRF Origin check rather than be triggerable by a bare GET.
r.Post("/auth/logout", app.Logout)
// BFF liveness (does not call the gateway).
r.Get("/healthz", app.GetHealthz)
r.Get("/readyz", app.GetReadyz)

r.Group(func(r chi.Router) {
r.Use(app.auth.Handler)

r.Get("/auth/session", app.GetSession)
r.Get("/auth/whoami", app.GetWhoAmI)
r.Get("/gateway", app.GetGateway)
r.Get("/draft-summary", app.GetDraftSummary)
Expand Down Expand Up @@ -167,59 +141,6 @@ func (app *App) Routes() http.Handler {
return r
}

// csrfMiddleware rejects cross-origin mutating requests. Cookie-based
// sessions reintroduce CSRF exposure that Bearer headers never had;
// SameSite=Strict on the session cookie is the primary defense, and this
// Origin check is defense-in-depth. Requests without an Origin header
// (curl, server-to-server) pass — they cannot carry a browser's cookies.
func (app *App) csrfMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch:
origin := r.Header.Get("Origin")
if origin != "" && !app.originAllowed(origin, requestOrigin(r)) {
writeError(w, http.StatusForbidden, "cross_origin_rejected", "cross-origin request rejected")
return
}
}
next.ServeHTTP(w, r)
})
}

func (app *App) originAllowed(origin, requestOrigin string) bool {
// Full-origin (scheme+host) match, so http:// can't pass for an https
// request. requestOrigin is the BFF's own scheme://host for this request.
if origin == requestOrigin {
return true
}
for _, allowed := range app.allowedOrigins {
if origin == allowed {
return true
}
}
return false
}

func (app *App) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
for _, allowed := range app.allowedOrigins {
if origin == allowed {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
break
}
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}

// serveStatic serves the built frontend with SPA fallback to index.html.
func (app *App) serveStatic(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
Expand Down
Loading
Loading