deploy configuration
This commit is contained in:
parent
9ba3c47d82
commit
dac02c61c3
45 changed files with 1405 additions and 582 deletions
|
|
@ -1,6 +1,7 @@
|
|||
// Package config carries the runtime settings for the demo CMS. There is no
|
||||
// Package config carries the runtime settings for the CMS. There is no
|
||||
// database to configure: content is seeded in memory and optionally mirrored to
|
||||
// a single JSON file.
|
||||
// a single JSON file. Editing rights come from Go Loose, from the single-sign-on
|
||||
// proxy, or from the demo profile, depending on what is configured.
|
||||
package config
|
||||
|
||||
import "os"
|
||||
|
|
@ -8,8 +9,26 @@ import "os"
|
|||
type Config struct {
|
||||
HTTPAddr, Profile, ContentFile, PostgresURL string
|
||||
AllowedOrigins, SSOHeader, SSOToken, SSOCookie string
|
||||
GoLoose GoLooseConfig
|
||||
}
|
||||
|
||||
// GoLooseConfig describes the Go Loose hosted-login integration. Go Tell is
|
||||
// served from a single host, so one client application covers every request:
|
||||
// Tenant picks the Go Loose tenant that owns the login, AuthDomain is the
|
||||
// identity domain shared by all tenants, and AppDomain is the public host of
|
||||
// the CMS, which also determines the registered redirect URI.
|
||||
type GoLooseConfig struct {
|
||||
Tenant string
|
||||
AuthDomain string
|
||||
AppDomain string
|
||||
CAFile string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
// Enabled reports whether Go Loose client credentials are configured.
|
||||
func (g GoLooseConfig) Enabled() bool { return g.ClientID != "" && g.ClientSecret != "" }
|
||||
|
||||
func Load() Config {
|
||||
return Config{
|
||||
HTTPAddr: get("CMS_HTTP_ADDR", ":8080"),
|
||||
|
|
@ -20,6 +39,14 @@ func Load() Config {
|
|||
SSOHeader: get("CMS_SSO_HEADER", "X-SSO-Token"),
|
||||
SSOToken: get("CMS_SSO_TOKEN", ""),
|
||||
SSOCookie: get("CMS_SSO_COOKIE", ""),
|
||||
GoLoose: GoLooseConfig{
|
||||
Tenant: get("GO_LOOSE_TENANT", "tell"),
|
||||
AuthDomain: get("GO_LOOSE_AUTH_DOMAIN", "auth.dev"),
|
||||
AppDomain: get("GO_LOOSE_APP_DOMAIN", "tell.dev"),
|
||||
CAFile: os.Getenv("GO_LOOSE_CA_FILE"),
|
||||
ClientID: os.Getenv("GO_LOOSE_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("GO_LOOSE_CLIENT_SECRET"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
142
internal/golooseauth/browser.go
Normal file
142
internal/golooseauth/browser.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
// Package golooseauth builds the Go Loose hosted-login client used by the CMS.
|
||||
package golooseauth
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
goloose "github.com/danyel/go-loose/client"
|
||||
|
||||
"github.com/danyel/go-tell/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
// SessionCookie holds the Go Loose session token after a successful login.
|
||||
SessionCookie = "go_loose_user_session"
|
||||
// LoginPath starts the browser login flow.
|
||||
LoginPath = "/api/auth/login"
|
||||
// CallbackPath is the redirect URI registered with Go Loose.
|
||||
CallbackPath = "/api/auth/callback"
|
||||
// LogoutPath ends the browser session, locally and at the identity provider.
|
||||
LogoutPath = "/api/auth/logout"
|
||||
)
|
||||
|
||||
// Auth is the Go Loose browser client plus the session lookup the API needs.
|
||||
// The login, callback, and logout handlers come from the Go Loose client; this
|
||||
// type only adds a non-redirecting way to turn a request into a user, because
|
||||
// the client middleware answers anonymous requests with a redirect and the CMS
|
||||
// has to answer its own API with a status code.
|
||||
type Auth struct {
|
||||
*goloose.BrowserAuth
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New returns the browser client for the configured Go Loose application, or a
|
||||
// nil client when no credentials are set, which leaves the CMS with its other
|
||||
// identity sources. ClientID and ClientSecret are the values Go Loose shows once
|
||||
// when a client application is configured.
|
||||
func New(cfg config.GoLooseConfig) (*Auth, error) {
|
||||
if !cfg.Enabled() {
|
||||
return nil, nil
|
||||
}
|
||||
tenant := domain(cfg.Tenant)
|
||||
if tenant == "" || strings.Contains(tenant, ".") {
|
||||
return nil, fmt.Errorf("GO_LOOSE_TENANT must be a single lowercase tenant slug")
|
||||
}
|
||||
authDomain := domain(cfg.AuthDomain)
|
||||
if authDomain == "" {
|
||||
return nil, fmt.Errorf("GO_LOOSE_AUTH_DOMAIN is required when client credentials are configured")
|
||||
}
|
||||
appDomain := domain(cfg.AppDomain)
|
||||
if appDomain == "" {
|
||||
return nil, fmt.Errorf("GO_LOOSE_APP_DOMAIN is required when client credentials are configured")
|
||||
}
|
||||
httpClient, err := client(cfg.CAFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseURL := "https://" + tenant + "." + authDomain
|
||||
browser, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
|
||||
BaseURL: baseURL,
|
||||
ClientID: cfg.ClientID,
|
||||
ClientSecret: cfg.ClientSecret,
|
||||
RedirectURL: "https://" + appDomain + CallbackPath,
|
||||
AfterLoginURL: "/",
|
||||
AfterLogoutURL: "/",
|
||||
LoginPath: LoginPath,
|
||||
CookieName: SessionCookie,
|
||||
CookieSecure: true,
|
||||
HTTPClient: httpClient,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Auth{BrowserAuth: browser, baseURL: baseURL, httpClient: httpClient}, nil
|
||||
}
|
||||
|
||||
// Tenant reports the Go Loose tenant that owns the login.
|
||||
func (a *Auth) Tenant() string { return a.baseURL }
|
||||
|
||||
// SessionUser resolves the Go Loose session cookie of a request into a user.
|
||||
// Requests without a cookie, with a rejected session, or without a reachable
|
||||
// identity provider are simply not signed in.
|
||||
func (a *Auth) SessionUser(r *http.Request) (goloose.User, bool) {
|
||||
if a == nil {
|
||||
return goloose.User{}, false
|
||||
}
|
||||
cookie, err := r.Cookie(SessionCookie)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return goloose.User{}, false
|
||||
}
|
||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, a.baseURL+"/connect/userinfo", nil)
|
||||
if err != nil {
|
||||
return goloose.User{}, false
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+cookie.Value)
|
||||
response, err := a.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return goloose.User{}, false
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var user goloose.User
|
||||
if response.StatusCode != http.StatusOK ||
|
||||
json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&user) != nil {
|
||||
return goloose.User{}, false
|
||||
}
|
||||
return user, true
|
||||
}
|
||||
|
||||
// domain normalizes a configured host or tenant slug.
|
||||
func domain(value string) string {
|
||||
return strings.Trim(strings.ToLower(strings.TrimSpace(value)), ".")
|
||||
}
|
||||
|
||||
// client returns the HTTP client used to talk to Go Loose, trusting the extra
|
||||
// certificate authorities in caFile when one is configured. It is needed while
|
||||
// the identity domain is served by an internal certificate authority.
|
||||
func client(caFile string) (*http.Client, error) {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
if caFile != "" {
|
||||
pem, err := os.ReadFile(caFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read Go Loose CA: %w", err)
|
||||
}
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil || pool == nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("parse Go Loose CA %s", caFile)
|
||||
}
|
||||
transport.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
|
||||
}
|
||||
return &http.Client{Timeout: 5 * time.Second, Transport: transport}, nil
|
||||
}
|
||||
150
internal/golooseauth/browser_test.go
Normal file
150
internal/golooseauth/browser_test.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package golooseauth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
goloose "github.com/danyel/go-loose/client"
|
||||
|
||||
"github.com/danyel/go-tell/internal/config"
|
||||
)
|
||||
|
||||
func TestNewWithoutCredentialsIsDisabled(t *testing.T) {
|
||||
auth, err := New(config.GoLooseConfig{Tenant: "tell", AuthDomain: "auth.dev", AppDomain: "tell.dev"})
|
||||
if err != nil || auth != nil {
|
||||
t.Fatalf("New() = %#v, %v; want a disabled client", auth, err)
|
||||
}
|
||||
auth, err = New(config.GoLooseConfig{ClientID: "client"})
|
||||
if err != nil || auth != nil {
|
||||
t.Fatalf("New() with a half-configured client = %#v, %v; want a disabled client", auth, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsInvalidDomains(t *testing.T) {
|
||||
cases := map[string]config.GoLooseConfig{
|
||||
"tenant with a dot": {Tenant: "tell.dev", AuthDomain: "auth.dev", AppDomain: "tell.dev", ClientID: "c", ClientSecret: "s"},
|
||||
"empty tenant": {Tenant: " ", AuthDomain: "auth.dev", AppDomain: "tell.dev", ClientID: "c", ClientSecret: "s"},
|
||||
"empty auth domain": {Tenant: "tell", ClientID: "c", ClientSecret: "s"},
|
||||
"empty app domain": {Tenant: "tell", AuthDomain: "auth.dev", ClientID: "c", ClientSecret: "s"},
|
||||
}
|
||||
for name, cfg := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := New(cfg); err == nil {
|
||||
t.Fatal("New() error = nil, want a configuration error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBuildsSingleHostBrowserClient(t *testing.T) {
|
||||
auth, err := New(config.GoLooseConfig{
|
||||
Tenant: " Tell ", AuthDomain: ".auth.dev.", AppDomain: "Tell.Dev",
|
||||
ClientID: "client", ClientSecret: "secret",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if auth.Tenant() != "https://tell.auth.dev" {
|
||||
t.Fatalf("Tenant() = %q", auth.Tenant())
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
auth.LoginHandler(recorder, httptest.NewRequest(http.MethodGet, LoginPath, nil))
|
||||
if recorder.Code != http.StatusFound {
|
||||
t.Fatalf("login status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
target, err := url.Parse(recorder.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if target.Scheme+"://"+target.Host != "https://tell.auth.dev" || target.Path != "/connect/authorize" {
|
||||
t.Fatalf("unexpected login target: %s", target)
|
||||
}
|
||||
if target.Query().Get("client_id") != "client" ||
|
||||
target.Query().Get("redirect_uri") != "https://tell.dev"+CallbackPath ||
|
||||
target.Query().Get("state") == "" {
|
||||
t.Fatalf("unexpected login query: %s", target.RawQuery)
|
||||
}
|
||||
cookies := recorder.Result().Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name == SessionCookie || !cookies[0].HttpOnly {
|
||||
t.Fatalf("expected an HTTP-only state cookie scoped to the callback, got %#v", cookies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionUserResolvesCookie(t *testing.T) {
|
||||
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer session-token" {
|
||||
http.Error(w, "missing bearer token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"id":"user-1","email":"owner@tell.dev","display_name":"Owner","tenant_slug":"tell"}`))
|
||||
}))
|
||||
defer identity.Close()
|
||||
|
||||
auth := testAuth(t, identity)
|
||||
request := httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil)
|
||||
if _, ok := auth.SessionUser(request); ok {
|
||||
t.Fatal("SessionUser() without a cookie = true, want false")
|
||||
}
|
||||
if _, ok := auth.SessionUser(httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil)); ok {
|
||||
t.Fatal("SessionUser() without a cookie = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionUserRejectsRejectedSession(t *testing.T) {
|
||||
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "no such session", http.StatusUnauthorized)
|
||||
}))
|
||||
defer identity.Close()
|
||||
|
||||
auth := testAuth(t, identity)
|
||||
request := httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil)
|
||||
request.AddCookie(&http.Cookie{Name: SessionCookie, Value: "expired"})
|
||||
if user, ok := auth.SessionUser(request); ok {
|
||||
t.Fatalf("SessionUser() with a rejected session = %#v, want no user", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionUserReturnsIdentity(t *testing.T) {
|
||||
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"id":"user-1","email":"owner@tell.dev","display_name":"Owner","tenant_slug":"tell"}`))
|
||||
}))
|
||||
defer identity.Close()
|
||||
|
||||
auth := testAuth(t, identity)
|
||||
request := httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil)
|
||||
request.AddCookie(&http.Cookie{Name: SessionCookie, Value: "session-token"})
|
||||
user, ok := auth.SessionUser(request)
|
||||
if !ok {
|
||||
t.Fatal("SessionUser() = false, want the session user")
|
||||
}
|
||||
if user.Email != "owner@tell.dev" || user.DisplayName != "Owner" || user.TenantSlug != "tell" {
|
||||
t.Fatalf("unexpected user: %#v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilAuthHasNoSession(t *testing.T) {
|
||||
var auth *Auth
|
||||
if user, ok := auth.SessionUser(httptest.NewRequest(http.MethodGet, "/", nil)); ok {
|
||||
t.Fatalf("SessionUser() on a disabled client = %#v, want no user", user)
|
||||
}
|
||||
}
|
||||
|
||||
// testAuth wires an Auth to a local identity server, which is what the client
|
||||
// would talk to once the base URL points somewhere reachable in a test.
|
||||
func testAuth(t *testing.T, identity *httptest.Server) *Auth {
|
||||
t.Helper()
|
||||
browser, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
|
||||
BaseURL: identity.URL, ClientID: "client", ClientSecret: "secret",
|
||||
RedirectURL: "https://tell.dev" + CallbackPath, LoginPath: LoginPath,
|
||||
CookieName: SessionCookie, HTTPClient: identity.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Auth{BrowserAuth: browser, baseURL: identity.URL, httpClient: identity.Client()}
|
||||
}
|
||||
|
|
@ -33,9 +33,9 @@ func TestSSOTokenVerifierChecksExpectedToken(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestSSOTokenVerifierCookieFallback(t *testing.T) {
|
||||
v := NewSSOTokenVerifier("X-SSO-Token", "", "cms_session")
|
||||
v := NewSSOTokenVerifier("X-SSO-Token", "", "go_tell_session")
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
r.AddCookie(&http.Cookie{Name: "cms_session", Value: "cookie-token"})
|
||||
r.AddCookie(&http.Cookie{Name: "go_tell_session", Value: "cookie-token"})
|
||||
if !v.Authenticated(r) {
|
||||
t.Fatal("cookie token must be authenticated")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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{})
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue