deploy configuration

This commit is contained in:
Daniel Noulet 2026-10-03 04:41:31 +02:00
parent 9ba3c47d82
commit dac02c61c3
45 changed files with 1405 additions and 582 deletions

View file

@ -8,22 +8,27 @@ import (
"net/http"
"strings"
"github.com/danyel/go-cms/internal/security"
"github.com/danyel/go-cms/internal/service"
goloose "github.com/danyel/go-loose/client"
"github.com/danyel/go-tell/internal/golooseauth"
"github.com/danyel/go-tell/internal/security"
"github.com/danyel/go-tell/internal/service"
)
// Handler serves the CMS API and the embedded React app from one binary.
// Reading content is anonymous; every mutation requires the identity asserted by
// the upstream SSO proxy.
// Reading content is anonymous; every mutation requires an authenticated
// editor: a Go Loose browser session, the identity asserted by the upstream
// SSO proxy, or a demo user.
type Handler struct {
content service.IContentService
assets fs.FS
sso security.ITokenVerifier
browser *golooseauth.Auth
demo *service.DemoAuth
}
func NewHandler(content service.IContentService, assets fs.FS, sso security.ITokenVerifier, demo ...*service.DemoAuth) *Handler {
h := &Handler{content: content, assets: assets, sso: sso}
func NewHandler(content service.IContentService, assets fs.FS, sso security.ITokenVerifier, browser *golooseauth.Auth, demo ...*service.DemoAuth) *Handler {
h := &Handler{content: content, assets: assets, sso: sso, browser: browser}
if len(demo) > 0 {
h.demo = demo[0]
}
@ -35,6 +40,9 @@ func (h *Handler) Routes(origins string) http.Handler {
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
jsonWrite(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc(golooseauth.LoginPath, h.login)
mux.HandleFunc(golooseauth.CallbackPath, h.callback)
mux.HandleFunc(golooseauth.LogoutPath, h.logout)
mux.HandleFunc("/api/auth/session", h.session)
mux.HandleFunc("/api/auth/sudo", h.sudo)
mux.HandleFunc("/api/auth/users", h.users)
@ -48,9 +56,51 @@ func (h *Handler) Routes(origins string) http.Handler {
return cors(mux, origins, h.sso.HeaderName())
}
// session reports who the caller is. When Go Loose is configured the response
// also carries the login and logout endpoints so the UI can offer the browser
// login instead of a proxy redirect.
func (h *Handler) session(w http.ResponseWriter, r *http.Request) {
username, authenticated := h.identity(r)
jsonWrite(w, http.StatusOK, map[string]any{"authenticated": authenticated, "canEdit": authenticated, "username": username})
response := map[string]any{"authenticated": authenticated, "canEdit": authenticated, "username": username}
if user, ok := h.browserUser(r); ok {
response["email"] = user.Email
response["displayName"] = user.DisplayName
}
if h.browser != nil {
response["loginUrl"] = golooseauth.LoginPath
response["logoutUrl"] = golooseauth.LogoutPath
}
jsonWrite(w, http.StatusOK, response)
}
func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
if h.browser == nil {
writeAuthUnavailable(w)
return
}
h.browser.LoginHandler(w, r)
}
func (h *Handler) callback(w http.ResponseWriter, r *http.Request) {
if h.browser == nil {
writeAuthUnavailable(w)
return
}
h.browser.CallbackHandler(w, r)
}
func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
if h.browser == nil {
writeAuthUnavailable(w)
return
}
h.browser.LogoutHandler(w, r)
}
func writeAuthUnavailable(w http.ResponseWriter) {
jsonWrite(w, http.StatusNotFound, map[string]string{
"error": "Go Loose login is not configured: set GO_LOOSE_CLIENT_ID and GO_LOOSE_CLIENT_SECRET, then restart the server",
})
}
func (h *Handler) protected(w http.ResponseWriter, r *http.Request) {
@ -182,6 +232,9 @@ func (h *Handler) contentDetail(w http.ResponseWriter, r *http.Request) {
}
}
// identity resolves the editor behind a request. The demo profile wins, then
// the SSO proxy, then the Go Loose browser session: the session is checked last
// because it costs a call to the identity provider.
func (h *Handler) identity(r *http.Request) (string, bool) {
if h.demo != nil {
token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
@ -192,9 +245,31 @@ func (h *Handler) identity(r *http.Request) (string, bool) {
if h.sso.Authenticated(r) {
return "sso-owner", true
}
if user, ok := h.browserUser(r); ok {
return editorName(user), true
}
return "", false
}
// browserUser returns the Go Loose user carried by the request session, if any.
func (h *Handler) browserUser(r *http.Request) (goloose.User, bool) {
if h.browser == nil {
return goloose.User{}, false
}
user, ok := h.browser.SessionUser(r)
if !ok {
return goloose.User{}, false
}
return user, true
}
func editorName(user goloose.User) string {
if name := strings.TrimSpace(user.DisplayName); name != "" {
return name
}
return user.Email
}
// singlePageApp serves the embedded build and falls back to index.html so the
// client-side router owns every non-API path.
func (h *Handler) singlePageApp(w http.ResponseWriter, r *http.Request) {

View file

@ -8,8 +8,10 @@ import (
"testing"
"testing/fstest"
"github.com/danyel/go-cms/internal/security"
"github.com/danyel/go-cms/internal/service"
"github.com/danyel/go-tell/internal/config"
"github.com/danyel/go-tell/internal/golooseauth"
"github.com/danyel/go-tell/internal/security"
"github.com/danyel/go-tell/internal/service"
)
type fakeContent struct{ updated bool }
@ -31,12 +33,16 @@ func (f *fakeContent) Update(_ context.Context, c service.Content) (service.Cont
return c, nil
}
func newTestRoutes(content *fakeContent) http.Handler {
func newTestRoutes(content *fakeContent, browser ...*golooseauth.Auth) http.Handler {
assets := fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte("<!doctype html><div id=\"root\"></div>")},
"assets/index.js": &fstest.MapFile{Data: []byte("console.log('cms')")},
"assets/index.js": &fstest.MapFile{Data: []byte("console.log('go-tell')")},
}
return NewHandler(content, assets, security.NewSSOTokenVerifier("X-SSO-Token", "", "")).Routes("http://localhost:5173")
var auth *golooseauth.Auth
if len(browser) > 0 {
auth = browser[0]
}
return NewHandler(content, assets, security.NewSSOTokenVerifier("X-SSO-Token", "", ""), auth).Routes("http://localhost:5173")
}
func TestAnonymousCanReadButNotEdit(t *testing.T) {
@ -89,6 +95,68 @@ func TestProxyIdentityCanEdit(t *testing.T) {
}
}
func TestGoLooseLoginRedirectsToTheIdentityProvider(t *testing.T) {
routes := newTestRoutes(&fakeContent{}, testGoLooseAuth(t))
response := httptest.NewRecorder()
routes.ServeHTTP(response, httptest.NewRequest(http.MethodGet, golooseauth.LoginPath, nil))
if response.Code != http.StatusFound {
t.Fatalf("login status = %d, body = %s", response.Code, response.Body.String())
}
location := response.Header().Get("Location")
if !strings.HasPrefix(location, "https://tell.auth.dev/connect/authorize?") ||
!strings.Contains(location, "redirect_uri=https%3A%2F%2Ftell.dev%2Fapi%2Fauth%2Fcallback") {
t.Fatalf("unexpected login redirect: %s", location)
}
}
func TestSessionAdvertisesGoLooseEndpoints(t *testing.T) {
routes := newTestRoutes(&fakeContent{}, testGoLooseAuth(t))
response := httptest.NewRecorder()
routes.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/auth/session", nil))
if response.Code != http.StatusOK {
t.Fatalf("session status = %d", response.Code)
}
body := response.Body.String()
for _, want := range []string{`"authenticated":false`, `"loginUrl":"/api/auth/login"`, `"logoutUrl":"/api/auth/logout"`} {
if !strings.Contains(body, want) {
t.Fatalf("session = %s, want %s", body, want)
}
}
}
func TestSessionWithoutGoLooseHasNoLoginURL(t *testing.T) {
response := httptest.NewRecorder()
newTestRoutes(&fakeContent{}).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/auth/session", nil))
if strings.Contains(response.Body.String(), "loginUrl") {
t.Fatalf("session = %s, want no login endpoint", response.Body.String())
}
}
func TestGoLooseRoutesAreNotFoundWhenUnconfigured(t *testing.T) {
routes := newTestRoutes(&fakeContent{})
for _, path := range []string{golooseauth.LoginPath, golooseauth.CallbackPath, golooseauth.LogoutPath} {
response := httptest.NewRecorder()
routes.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
if response.Code != http.StatusNotFound || !strings.Contains(response.Body.String(), "GO_LOOSE_CLIENT_ID") {
t.Fatalf("%s without Go Loose = %d %s", path, response.Code, response.Body.String())
}
}
}
func testGoLooseAuth(t *testing.T) *golooseauth.Auth {
t.Helper()
auth, err := golooseauth.New(config.GoLooseConfig{
Tenant: "tell", AuthDomain: "auth.dev", AppDomain: "tell.dev",
ClientID: "glc_tell", ClientSecret: "secret",
})
if err != nil {
t.Fatal(err)
}
return auth
}
func TestServesEmbeddedAppForClientRoutes(t *testing.T) {
routes := newTestRoutes(&fakeContent{})