portal

command module
v0.3.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 11, 2026 License: BSD-3-Clause Imports: 38 Imported by: 0

README

portal

The server behind the go-fileshare UIs. It logs people in with OpenID Connect, keeps their session in a sealed cookie, buys a token for each fileshare server, and passes the UIs' Connect calls to those servers with the right token. The browser never holds a token. Pure Go, CGO_ENABLED=0, one binary.

go install github.com/go-fileshare/portal@latest
portal -config /etc/portal/portal.hcl

It keeps no state. Run as many copies as availability needs, behind any load balancer, with the same configuration and the same session keys: any copy serves any request.

This is phase 2 of the UI design. The two UIs it serves are phases 3 and 4: go-fileshare/console, the admin console, and go-fileshare/explorer, the file explorer.

Why a server in the middle

draft-ietf-oauth-browser-based-apps-27 §6.1 calls this a backend for frontend and recommends it for applications that handle personal data. A token held by JavaScript or wasm can be read by any script that runs in the page; a token held by the portal cannot. The page has a cookie that is HttpOnly, so script cannot read it, and sealed, so its holder learns nothing from it.

Configuration

listen     = "0.0.0.0:443"
public_url = "https://files.example.org"     # the callback is public_url + /callback

tls {
  cert_file = "/etc/portal/tls/fullchain.pem"   # re-read when it changes
  key_file  = "/etc/portal/tls/key.pem"
}

session {
  key_files        = ["/etc/portal/session.key"]   # openssl rand -base64 32 > session.key; chmod 600
  lifetime         = "8h"                          # from the login, however active
  max_cookie_bytes = 7600                          # see "Cookies" below
}

issuer "https://login.example.org" {
  client_id          = "fileshare-portal"
  client_secret_file = "/etc/portal/client.secret"
  # scopes = ["openid", "profile", "offline_access"]   # the default
}

server "paris" {
  label    = "Paris"
  url      = "https://fs-paris.example.org:8443"   # fileshare's admin { web { listen } }
  issuer   = "https://login.example.org"
  resource = "https://fs-paris.example.org/"       # RFC 8707: the token's audience
  # ca_file = "/etc/portal/fs-ca.pem"              # if its certificate is not publicly trusted
}

# a server whose shares the explorer browses: its WebDAV, and the audience
# fileshare's oidc block names (defaults to resource)
#   dav_url      = "https://fs-paris.example.org"
#   dav_resource = "https://fs-paris.example.org/dav/"

server "lyon" {
  url      = "https://fs-lyon.example.org:8443"
  issuer   = "https://login.example.org"
  resource = "https://fs-lyon.example.org/"
}

ui "console"  { dir = "/usr/share/fileshare-console" }
ui "explorer" { dir = "/usr/share/fileshare-explorer" }

On each fileshare server, the issuer block's audience is that server's resource here (fileshare's admin API over HTTPS):

admin {
  web {
    listen = "0.0.0.0:8443"
    issuer "https://login.example.org" {
      audience = "https://fs-paris.example.org/"
      groups   = ["fileshare-admins"]
    }
  }
}

portal -check -config … reads the configuration and every secret it names, and exits.

What it refuses to start with
  • a listener in the clear anywhere but loopback (behind something that terminates TLS), and a public_url that is not an https origin: the cookie is the session;
  • an issuer, a server or a provider endpoint in the clear (loopback aside);
  • a server with neither resource nor scope: its token would carry the client's default audience, and a token addressed to several servers can be replayed by any of them to the others (RFC 8707 §3);
  • a secret file that others may read, a session key that is not 32 bytes;
  • a provider whose discovery document names another issuer, or that offers PKCE without S256;
  • an issuer it cannot reach: discovery and the keys are read at start.

The provider

The portal is a confidential client using the authorization code flow with PKCE (S256), state, nonce, and its secret at the token endpoint (client_secret_basic). The protocol itself is go-authn/rp, which the native console shares. It needs:

  • refresh tokens for the client (offline_access, or the provider's equivalent). The login's own access token is not used: each server's token is bought with the refresh token. A login that brings no refresh token is refused, with the reason in the log;
  • a token addressed to one server at a time. The portal asks with the server's resource (RFC 8707 §2.2, on the refresh grant), or its scope for a provider that sets the audience from a scope instead, as Keycloak's audience mappers do;
  • https://<public_url>/callback as a redirect URI.

go-authn/bridge does all of this from v0.21.0. List every server's resource in the client's audience, and give the client refresh tokens:

client "portal" {
  secret_file      = "/etc/authn-bridge/portal.secret"
  redirect_uris    = ["https://files.example.org/callback"]
  audience         = ["https://fs-paris.example.org/", "https://fs-lyon.example.org/"]
  refresh_lifetime = "8h"
}

Before v0.21.0, bridge ignored resource and addressed every token to the whole audience, which is exactly the token the portal is built to avoid.

How tokens move

browser ── cookie ──► portal ── Bearer <token for paris> ──► fileshare paris
                        │
                        └──── Bearer <token for lyon> ───► fileshare lyon
  1. GET /login sends the browser to the provider; /callback checks the answer — state, issuer (RFC 9207), the code exchanged with the PKCE verifier, the ID token's signature, audience and nonce — and stores the session: who, until when, and the refresh token.
  2. POST /session/refresh {"servers": ["paris", "lyon"]} buys a token for each server named, one after the other, and keeps those alone.
  3. POST /api/paris/fileshare.admin.v1.AdminService/<Method> is passed to that server with its token. Only the admin service is passed.

The refresh token is spent in one place. A provider that rotates refresh tokens revokes the whole grant when a retired one comes back (RFC 9700 §4.14.2), and two requests refreshing at once would do exactly that. So the proxy never refreshes. When a server's token is missing or about to expire it answers unauthenticated with a Portal-Refresh: <server> header, and the UI calls /session/refresh and tries again — one refresh at a time across its tabs, with the browser's Web Locks API. If a refresh is lost anyway (the answer never reaches the browser), the grant ends and the person logs in again: the failure is a login, never a token that works for someone else.

Files, for the explorer: /dav/<server>/<share>/<path>

The go-fileshare explorer reaches a server's shares through the portal too: /dav/<server>/<share>/<path> is that server's WebDAV (dav_url), with a token addressed to it. The audience is fileshare's oidc block's, or dav_resource when it differs from the admin API's.

  • The root: /dav/<server>/ lists the shares the person may open (a PROPFIND of fileshare's /, which answers one from v0.29.0).
  • What passes: GET, HEAD, PUT, DELETE, PROPFIND (Depth: 0 or 1), MKCOL, MOVE, COPY. Nothing else (no LOCK, no PROPPATCH).
  • Destination: a MOVE's or COPY's must be on the same server, under /dav/<server>/; it is rewritten to the server's URL.
  • Size: a PUT is at most 16 MiB. A larger file goes in pieces: an empty PUT, then each piece with a Content-Range starting where the last ended (fileshare v0.29.0), then a MOVE to its name.
  • Downloads: a GET is the one request that need not carry Portal-Request, so that a download can be a plain navigation. One that another site starts is refused (Sec-Fetch-Site). Every other method needs the header, as /api does.

⛔ What a share holds is served inert. A file on a share is whatever somebody put there, and served from the portal's origin, a page with a script would run as the portal. It could not read the HttpOnly cookie, but it could call /api and /dav as the person. So every /dav answer carries:

  • Content-Security-Policy: sandbox; default-src 'none', in place of the portal's own (the server's is dropped, not added beside it);
  • X-Content-Type-Options: nosniff;
  • for a download, Content-Type: application/octet-stream and Content-Disposition: attachment.

The server's Set-Cookie never passes.

The UIs' side: github.com/go-fileshare/portal/client

What a UI behind the portal needs of it, as a package:

  • Session, LoginURL and Logout.
  • Doer(server, maxBody), an HTTP client for /api/<server>/... or /dav/<server>/.... It carries Portal-Request; on a 401 with Portal-Refresh, it refreshes the servers in use, at most five, and sends the request again.
  • EnsureToken, which buys the token before a download, because a navigation cannot refresh and retry.

A browser serializes refreshes across tabs with the Web Locks API: client.New(origin, nil, client.WebLocks(client.RefreshLock)). Every UI must take that one lock name: the UIs share the portal's origin, hence one session cookie and one refresh token, and a console tab refreshing beside an explorer tab would spend it twice. The package is tested against the real portal handler, not a stand-in.

Cookies

__Host-Http-portal.0, .1, … the session, sealed (AES-256-GCM, the cookie name as associated data). Secure, HttpOnly, SameSite=Strict, Path=/, no Domain
__Host-Http-portal-flow the login in progress, for ten minutes. SameSite=Lax, because the callback is a navigation coming from the provider's site

__Host- makes the browser refuse the cookie unless it is Secure, at /, and has no Domain, so no sibling host can set or read it. -Http- marks it as set over HTTP, never by script (draft-ietf-httpbis-layered-cookies). A browser that does not know that second prefix still enforces __Host-.

A session spreads over several cookies, because the tokens of several servers outgrow the 4096 bytes a browser need keep of one (RFC 6265 §6.1). A browser sends them all in one Cookie header line, and nginx refuses a line over 8 KiB by default (large_client_header_buffers 4 8k). So max_cookie_bytes defaults to 7600. Measured, that holds the tokens of 8 servers at once when a token is 600 bytes, 5 at 1000 bytes and 3 at 1500. The UI asks for the servers it is showing, so this is a limit on servers in view, not on servers configured. Raise it together with the load balancer's limit, up to 45600; past it, a refresh is refused (resource_exhausted) rather than half stored.

Session keys. The first key in key_files seals; every key opens. To replace one, put the new key first, deploy it everywhere, and remove the old key once every session sealed with it has expired (lifetime).

Cross-site requests

Everything the UI calls with fetch must carry Portal-Request: 1. A page on another origin cannot add that header without a CORS preflight, and the portal answers none. When the browser says where a request comes from (Origin, Sec-Fetch-Site), it must be the portal's own origin. Together with SameSite=Strict, this is the defence of BBA-27 §6.1.3.3. The UIs are served from the portal's origin, so none of this costs them a preflight.

What reaches a server is the call and the portal's token. The browser's cookies, its Authorization, Origin, Referer, Sec-Fetch-* and any Forwarded/X-Forwarded-* are removed, and a server's Set-Cookie is not passed back. A call is at most 1 MiB, as on fileshare's own listener.

Tests

go test ./...
go install github.com/go-fileshare/fileshare@v0.29.0
FILESHARE=$(go env GOPATH)/bin/fileshare go test -tags e2e -run E2E -v .

The unit tests run the whole flow against an OpenID provider written for them (idp_test.go). It is strict where a real one is: exact redirect URI, PKCE S256 only, rotating refresh tokens with reuse detection, and resource indicators that become the only audience. Its servers verify their tokens with go-authn/oidc, as fileshare does.

The e2e test puts the portal in front of two real fileshare servers and checks four things:

  • each server answers with the token bought for it;
  • a share created on one server appears there and not on the other;
  • the audit names the caller as oidc=<issuer> <sub> on the server that changed, and nobody on the other;
  • each server refuses the other's token. The control is that it accepts its own.

Each defence was removed in turn, and a test failed each time: resource indicator, header stripping, static header, Origin, Sec-Fetch-Site, nonce, state, iss, azp, upstream Set-Cookie, the admin-service filter, keeping the rotated refresh token, and SameSite=Strict.

Not yet

  • ACME for the portal's own certificate. go-authn/servercert has it; the configuration does not expose it yet.
  • Servers behind different issuers in one session. A session belongs to one issuer, and /session lists the other issuers' servers as not reachable.
  • RFC 8693 token exchange, for servers in another organisation.

Licence

BSD 3-Clause.

Documentation

Overview

Command portal is the server behind the go-fileshare UIs: it logs people in with OpenID Connect, keeps their session in a sealed cookie, buys a token for each fileshare server, and passes the UIs' Connect calls to the servers with those tokens. The browser never holds a token.

portal -config /etc/portal/portal.hcl

It keeps no state: run as many copies as availability needs, behind any load balancer, with the same configuration and session keys.

Directories

Path Synopsis
Package client is what a UI behind the portal needs of it: who is logged in, which servers they reach, and an HTTP client per server that carries the portal's static header and, when the portal says a server's token is missing or expiring, refreshes -- once, one refresh at a time -- and sends the request again.
Package client is what a UI behind the portal needs of it: who is logged in, which servers they reach, and an HTTP client per server that carries the portal's static header and, when the portal says a server's token is missing or expiring, refreshes -- once, one refresh at a time -- and sends the request again.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL