feat: account menu, database roles, and single logout

Bundles the console work from the current branch: the header account
control, database-backed roles and permissions, and signing out across
the applications that share this sign-in.

The account control replaces a separate identity chip and a separate
sign-out button, which meant the header carried two controls describing
one fact. web/account.js is mounted into every page by a placeholder and
owns the keyboard handling, the outside-click behaviour and the sign-out
form once. It also fills the trigger's name, which the previous markup
left empty.

Signing out revokes the hosted-login bearer sessions this installation
issued, then redirects to the provider's end_session_endpoint when the
discovery document advertises one. Applications that authenticate
against this installation therefore lose their session too; one pointed
straight at an identity provider is out of its reach and is unchanged.

Roles and permissions become rows rather than a closed enum, so a tenant
can extend the catalog without a migration, and a role can no longer be
granted a capability that is not in it. requireSameOrigin now compares
against the origin the request arrived on instead of the configured base
URL, which defaulted to http://localhost:8080 and so rejected every
write in a deployment that never set it.

Two bugs found while verifying in a browser:

  - A profile picture never rendered. avatarURL built an absolute URL
    from the configured base URL, and the page's own img-src 'self'
    blocked it. The console's own pages now take a root-relative path;
    only /connect/userinfo, read from another origin, needs an absolute
    one, and it follows the request.
  - absoluteURL preferred the configuration over the request while its
    comment claimed the opposite, which also misdirected the logout
    redirect.

Card layout: the footer was one two-column row shared between a label
and a forty-character identifier, so the identifier took the width and
crushed its label into three stacked words. Each pair is a line now, the
value truncates with the full text in the title, and cards align their
footers across a row.
This commit is contained in:
Daniel Noulet 2026-10-05 19:18:07 +02:00
parent 3fdc8cb489
commit c7c81fb65c
43 changed files with 2248 additions and 298 deletions

View file

@ -0,0 +1,366 @@
package server
import (
"context"
"database/sql"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
neturl "net/url"
"os"
"strings"
"testing"
"golang.org/x/oauth2"
"github.com/danyel/go-loose/internal/config"
"github.com/danyel/go-loose/internal/database"
"github.com/danyel/go-loose/internal/session"
"github.com/danyel/go-loose/internal/store"
"github.com/danyel/go-loose/web"
)
// logoutServer builds a server with a session manager so logout can be exercised
// end to end. The store is nil, so the client-session revocation is expected to
// panic-free no-op only where it is guarded; these tests therefore exercise the
// redirect behaviour, which is what a person sees.
func logoutServer(t *testing.T) *Server {
t.Helper()
secret := strings.Repeat("s", 32)
return &Server{
cfg: config.Config{BaseURL: "https://auth.example.test", AuthDomain: "auth.example.test"},
sessions: session.New(secret, false),
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
}
// TestLogoutClearsTheSessionAndReturnsToSignIn is the baseline: with no identity
// provider logout endpoint configured, signing out lands back on the sign-in page.
func TestLogoutClearsTheSessionAndReturnsToSignIn(t *testing.T) {
server := logoutServer(t)
response := httptest.NewRecorder()
server.authLogout(response, httptest.NewRequest(http.MethodPost, "https://auth.example.test/auth/logout", nil))
if response.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want %d", response.Code, http.StatusSeeOther)
}
if location := response.Header().Get("Location"); location != "/login" {
t.Fatalf("location = %q, want /login", location)
}
var cleared bool
for _, cookie := range response.Result().Cookies() {
if cookie.MaxAge < 0 {
cleared = true
}
}
if !cleared {
t.Error("logout did not clear the session cookie")
}
}
// TestLogoutEndsTheProviderSessionWhenAdvertised covers the cross-application half:
// when the provider publishes an end_session_endpoint the browser is sent there, so
// the single sign-on session the other applications share is ended too.
func TestLogoutEndsTheProviderSessionWhenAdvertised(t *testing.T) {
server := logoutServer(t)
server.endSession = "https://idp.example.test/protocol/openid-connect/logout"
server.oauth = &oauth2ConfigStub
response := httptest.NewRecorder()
server.authLogout(response, httptest.NewRequest(http.MethodPost, "https://auth.example.test/auth/logout", nil))
location := response.Header().Get("Location")
if !strings.HasPrefix(location, server.endSession) {
t.Fatalf("location = %q, want the provider logout endpoint", location)
}
for _, want := range []string{
"client_id=go-loose",
"post_logout_redirect_uri=https%3A%2F%2Fauth.example.test%2Flogin",
} {
if !strings.Contains(location, want) {
t.Errorf("location = %q, want it to contain %q", location, want)
}
}
}
// TestLogoutSkipsTheProviderWhenItIsUnconfigured documents the fallback: a
// deployment whose provider does not advertise a logout endpoint still signs the
// user out locally rather than erroring.
func TestLogoutSkipsTheProviderWhenItIsUnconfigured(t *testing.T) {
for name, configure := range map[string]func(*Server){
"no endpoint": func(s *Server) {},
"no client id": func(s *Server) { s.endSession = "https://idp.example.test/logout" },
"unparseable": func(s *Server) { s.endSession = "://nope"; s.oauth = &oauth2ConfigStub },
"nothing set up": func(s *Server) { s.endSession = "" },
} {
t.Run(name, func(t *testing.T) {
server := logoutServer(t)
configure(server)
response := httptest.NewRecorder()
server.authLogout(response, httptest.NewRequest(http.MethodPost, "https://auth.example.test/auth/logout", nil))
if location := response.Header().Get("Location"); location != "/login" {
t.Fatalf("location = %q, want the local fallback /login", location)
}
})
}
}
// TestAccountMenuIsEmbedded guards the component: it is mounted by every page, so a
// page that forgets the placeholder loses the whole control.
func TestAccountMenuIsEmbedded(t *testing.T) {
for _, page := range []string{"index.html", "profile.html", "roles.html"} {
t.Run(page, func(t *testing.T) {
content, err := web.Files.ReadFile(page)
if err != nil {
t.Fatalf("%s must be embedded: %v", page, err)
}
text := string(content)
for _, marker := range []string{"data-account-menu", "/assets/account.js"} {
if !strings.Contains(text, marker) {
t.Errorf("%s is missing %q", page, marker)
}
}
// The account control replaces the separate sign-out button, so the
// page must not still carry one or the header shows two of each.
if strings.Contains(text, `<form action="/auth/logout"`) {
t.Errorf("%s still renders its own logout form", page)
}
if strings.Contains(text, `id="identity"`) {
t.Errorf("%s still renders the old identity chip", page)
}
})
}
}
func TestAccountMenuScriptIsServed(t *testing.T) {
server := &Server{cfg: config.Config{BaseURL: "https://auth.example.test"}}
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/assets/account.js", nil)
request.SetPathValue("name", "account.js")
response := httptest.NewRecorder()
server.asset(response, request)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", response.Code, http.StatusOK)
}
body := response.Body.String()
// The sign-out item has to be a form post, not a fetch, so that signing out
// still works with scripting unavailable.
if !strings.Contains(body, `method="post"`) || !strings.Contains(body, "/auth/logout") {
t.Error("the account menu must post to /auth/logout to sign out")
}
}
// TestRowActionsDoNotUseCardStyling pins the table fix. .card-action is
// width:100% with a 16px top margin for the foot of a card, which made two buttons
// stacked in a narrow cell taller than the rest of the row.
func TestRowActionsDoNotUseCardStyling(t *testing.T) {
for _, page := range []string{"app.js", "roles.js"} {
content, err := web.Files.ReadFile(page)
if err != nil {
t.Fatalf("%s must be embedded: %v", page, err)
}
if strings.Contains(string(content), "table-actions") == false {
t.Errorf("%s does not use the table action wrapper", page)
}
}
css, err := web.Files.ReadFile("style.css")
if err != nil {
t.Fatalf("style.css must be embedded: %v", err)
}
if !strings.Contains(string(css), ".table-action{") {
t.Error("style.css has no .table-action rule, so row buttons would be unstyled")
}
}
// oauth2ConfigStub carries the client id logout needs without reaching a provider.
var oauth2ConfigStub = oauth2.Config{ClientID: "go-loose"}
// TestLogoutRevokesHostedLoginSessions is the test that matters for cross-application
// sign-out. Go Loose issues the bearer tokens the tenant applications authenticate
// with, so revoking them here is what makes those applications reject the user
// without ever being told. Both halves need real rows: a real session cookie and a
// real client session.
func TestLogoutRevokesHostedLoginSessions(t *testing.T) {
ctx := t.Context()
// The store is opened over PostgreSQL, which is what this project uses; there is
// no in-memory engine to substitute. The test skips when none is reachable rather
// than failing, so the rest of the suite still runs without a database.
db, err := database.Open(ctx, testDatabaseURL(t))
if err != nil {
t.Skipf("no test database available: %v", err)
}
defer db.Close()
if err := resetSchema(ctx, db); err != nil {
t.Fatalf("reset schema: %v", err)
}
if err := database.Migrate(ctx, db); err != nil {
t.Fatalf("migrate: %v", err)
}
st := store.New(db)
// A tenant with an application the owner can sign in to, and a live hosted-login
// session issued from it.
owner, err := st.UpsertUser(ctx, "local:owner@example.test", "owner@example.test", "Owner")
if err != nil {
t.Fatalf("upsert owner: %v", err)
}
tenant, err := st.CreateTenant(ctx, owner.ID, "acme", "Acme")
if err != nil {
t.Fatalf("create tenant: %v", err)
}
app, err := st.CreateApplication(ctx, owner.ID, store.Application{
TenantID: tenant.ID, Slug: "client", Name: "Client", AllowedHosts: []string{},
})
if err != nil {
t.Fatalf("create application: %v", err)
}
secretHash := []byte("hashed-client-secret")
if _, err := st.ConfigureClient(ctx, owner.ID, app.ID, []string{"https://app.example.test/cb"}, secretHash); err != nil {
t.Fatalf("configure client: %v", err)
}
ownerRole, err := st.SystemRoleID(ctx, "owner")
if err != nil {
t.Fatalf("owner role: %v", err)
}
if err := st.SetUserAccess(ctx, owner.ID, tenant.ID, owner.ID, ownerRole, []string{app.ID}); err != nil {
t.Fatalf("grant application access: %v", err)
}
codeHash := []byte("hashed-authorization-code")
if err := st.CreateAuthorizationCode(ctx, app.ID, owner.ID, "https://app.example.test/cb", codeHash); err != nil {
t.Fatalf("create authorization code: %v", err)
}
tokenHash := []byte("hashed-session-token")
if _, _, err := st.ExchangeAuthorizationCode(ctx, app.ClientID, "https://app.example.test/cb", secretHash, codeHash, tokenHash); err != nil {
t.Fatalf("exchange authorization code: %v", err)
}
before, err := st.CountActiveClientSessions(ctx, owner.ID)
if err != nil {
t.Fatalf("count sessions: %v", err)
}
if before != 1 {
t.Fatalf("active client sessions before logout = %d, want 1", before)
}
// A real session cookie, so the handler resolves an identity and takes the
// revocation path rather than the anonymous one.
secret := strings.Repeat("s", 32)
manager := session.New(secret, false)
server := &Server{
cfg: config.Config{BaseURL: "https://auth.example.test", AuthDomain: "auth.example.test"},
store: st,
sessions: manager,
// The handler logs how many sessions it revoked, so a discard logger is
// needed rather than leaving it nil.
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
issued := httptest.NewRecorder()
if err := manager.Set(issued, session.Claims{UserID: owner.ID, Management: true}); err != nil {
t.Fatalf("issue session: %v", err)
}
var sessionCookie *http.Cookie
for _, cookie := range issued.Result().Cookies() {
if cookie.Name == "go_loose_session" {
sessionCookie = cookie
}
}
if sessionCookie == nil {
t.Fatal("no session cookie was issued")
}
request := httptest.NewRequest(http.MethodPost, "https://auth.example.test/auth/logout", nil)
request.AddCookie(sessionCookie)
response := httptest.NewRecorder()
server.authLogout(response, request)
after, err := st.CountActiveClientSessions(ctx, owner.ID)
if err != nil {
t.Fatalf("count sessions: %v", err)
}
if after != 0 {
t.Fatalf("active client sessions after logout = %d, want 0: the other applications would still accept these tokens", after)
}
// The token itself must stop resolving, which is what the tenant application
// experiences as being signed out.
if _, err := st.ClientUserByToken(ctx, tokenHash); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("token still resolves after logout: %v, want ErrNotFound", err)
}
}
// testDatabaseURL returns a database for the store-backed tests to reset and
// migrate.
//
// It reads its own variable rather than GO_LOOSE_TEST_DATABASE_URL, and defaults to
// a different database again. Go runs package tests in parallel and both packages
// drop every table, so pointing them at one database makes them fight over the
// schema; deriving this from the shared variable would reproduce exactly that.
func testDatabaseURL(t *testing.T) string {
url := envOr("GO_LOOSE_TEST_SERVER_DATABASE_URL",
"postgres://goloose:goloose@localhost:5433/goloose_server_test?sslmode=disable")
requireScratchDatabase(t, url)
return url
}
// requireScratchDatabase refuses to run against anything that looks like a real
// database, because resetSchema drops every table in the public schema. The
// database name must contain "test" unless the operator opts out explicitly.
func requireScratchDatabase(t *testing.T, rawURL string) {
t.Helper()
if os.Getenv("GO_LOOSE_TEST_DATABASE_DESTRUCTIVE") == "1" {
return
}
parsed, err := neturl.Parse(rawURL)
if err != nil {
t.Fatalf("parse test database URL: %v", err)
}
name := strings.TrimPrefix(parsed.Path, "/")
if !strings.Contains(strings.ToLower(name), "test") {
t.Fatalf("refusing to run store-backed tests against database %q: resetSchema drops every table. "+
"Point it at a scratch database whose name contains \"test\" (run `make integration-db` to create one), "+
"or set GO_LOOSE_TEST_DATABASE_DESTRUCTIVE=1 to override.", name)
}
}
// envOr returns the environment value or a fallback.
func envOr(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return value
}
return fallback
}
// resetSchema drops every table so a store-backed test starts from the same state a
// fresh installation has. The tables live in a scratch database; the same helper
// exists in the store package's own tests.
func resetSchema(ctx context.Context, db *sql.DB) error {
rows, err := db.QueryContext(ctx, `SELECT tablename FROM pg_tables WHERE schemaname = 'public'`)
if err != nil {
return err
}
var tables []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
rows.Close()
return err
}
tables = append(tables, name)
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for _, name := range tables {
if _, err := db.ExecContext(ctx, `DROP TABLE IF EXISTS "`+name+`" CASCADE`); err != nil {
return err
}
}
return nil
}

View file

@ -27,10 +27,10 @@ type clientUserResponse struct {
Permissions []string `json:"permissions"`
}
func (s *Server) clientUser(user store.ClientUser) clientUserResponse {
func (s *Server) clientUser(r *http.Request, user store.ClientUser) clientUserResponse {
return clientUserResponse{
ID: user.ID, Email: user.Email, DisplayName: user.DisplayName,
AvatarURL: s.avatarURL(user.AvatarKey), TenantID: user.TenantID,
AvatarURL: s.avatarURL(r, user.AvatarKey), TenantID: user.TenantID,
TenantSlug: user.TenantSlug, ApplicationID: user.ApplicationID, Application: user.Application,
Role: user.Role, Permissions: user.Permissions,
}
@ -124,7 +124,7 @@ func (s *Server) clientToken(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]any{
"access_token": token, "token_type": "Bearer",
"expires_in": int(time.Until(expiresAt).Seconds()), "user": s.clientUser(user),
"expires_in": int(time.Until(expiresAt).Seconds()), "user": s.clientUser(r, user),
})
}
@ -143,7 +143,7 @@ func (s *Server) clientUserInfo(w http.ResponseWriter, r *http.Request) {
s.internalError(w, "load client user", err)
return
}
writeJSON(w, http.StatusOK, s.clientUser(user))
writeJSON(w, http.StatusOK, s.clientUser(r, user))
}
func (s *Server) clientLogout(w http.ResponseWriter, r *http.Request) {

View file

@ -12,8 +12,6 @@ import (
"github.com/danyel/go-loose/web"
)
const googleDemoClientID = "554628171917-forvpfs44pqajv77dfhl503prfqnhor3.apps.googleusercontent.com"
func (s *Server) installPage(w http.ResponseWriter, r *http.Request) {
if s.installed.Load() {
http.Redirect(w, r, "/login", http.StatusSeeOther)
@ -31,7 +29,6 @@ func (s *Server) installPage(w http.ResponseWriter, r *http.Request) {
}
page := strings.NewReplacer(
"{{CSRF_TOKEN}}", html.EscapeString(csrfToken),
"{{GOOGLE_CLIENT_ID}}", googleDemoClientID,
"{{CALLBACK_URL}}", html.EscapeString(s.cfg.OIDCRedirectURL),
).Replace(string(content))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@ -56,38 +53,44 @@ func (s *Server) install(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, "invalid installer form; reload the installer")
return
}
clientID := strings.TrimSpace(r.Form.Get("oidc_client_id"))
clientSecret := strings.TrimSpace(r.Form.Get("oidc_client_secret"))
if clientID == "" || clientSecret == "" {
writeError(w, http.StatusBadRequest, "Google client ID and client secret are required")
return
}
if err := s.configureOIDC(r.Context(), "https://accounts.google.com", clientID, clientSecret); err != nil {
writeError(w, http.StatusBadGateway, "Google OIDC discovery failed")
return
}
ciphertext, err := secretbox.Encrypt(s.cfg.SessionSecret, clientSecret)
if err != nil {
s.internalError(w, "encrypt OIDC secret", err)
issuer := strings.TrimSpace(r.Form.Get("field1"))
clientID := strings.TrimSpace(r.Form.Get("field2"))
clientSecret := strings.TrimSpace(r.Form.Get("field3"))
redirectURL := strings.TrimSpace(r.Form.Get("field4"))
if issuer == "" || clientID == "" || clientSecret == "" || redirectURL == "" {
writeError(w, http.StatusBadRequest, "All four fields are required")
return
}
seedDemo := r.Form.Get("seed_demo") == "on"
var demoUsers []store.DemoUser
var err error
if seedDemo {
demoUsers, err = buildDemoUsers()
if err != nil {
s.internalError(w, "hash demo credentials", err)
return
}
} else {
demoUsers = nil
}
ciphertext, encErr := secretbox.Encrypt(s.cfg.SessionSecret, clientSecret)
if encErr != nil {
s.internalError(w, "encrypt OIDC secret", encErr)
return
}
err = s.store.Install(r.Context(), store.Installation{
OIDCIssuer: "https://accounts.google.com", OIDCClientID: clientID,
OIDCSecretCiphertext: ciphertext, SeededDemo: seedDemo,
OIDCIssuer: issuer, OIDCClientID: clientID,
OIDCSecretCiphertext: ciphertext, OIDCRedirectURL: redirectURL, SeededDemo: seedDemo,
}, demoUsers)
if err != nil {
s.internalError(w, "complete installation", err)
return
}
// Configure OIDC in the running server
if err := s.configureOIDC(r.Context(), issuer, clientID, clientSecret, redirectURL); err != nil {
s.internalError(w, "configure OIDC", err)
return
}
s.installed.Store(true)
if err := s.ensureLocalAdministrator(r.Context()); err != nil {
s.internalError(w, "bootstrap local administrator", err)

View file

@ -9,23 +9,51 @@ import (
"golang.org/x/oauth2"
)
func (s *Server) configureOIDC(ctx context.Context, issuer, clientID, clientSecret string) error {
func (s *Server) configureOIDC(ctx context.Context, issuer, clientID, clientSecret, redirectURL string) error {
provider, err := oidc.NewProvider(ctx, issuer)
if err != nil {
return fmt.Errorf("discover OIDC provider: %w", err)
}
config := &oauth2.Config{
ClientID: clientID, ClientSecret: clientSecret,
Endpoint: provider.Endpoint(), RedirectURL: s.cfg.OIDCRedirectURL,
Endpoint: provider.Endpoint(), RedirectURL: redirectURL,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
s.authMu.Lock()
s.oauth = config
s.verifier = provider.Verifier(&oidc.Config{ClientID: clientID})
// The logout endpoint is not part of the OAuth endpoint set, so it is read out
// of the discovery document separately. A provider that does not advertise one
// leaves this empty, which is what makes signing out fall back to clearing the
// local session alone.
s.endSession = providerLogoutEndpoint(provider)
s.authMu.Unlock()
return nil
}
// providerLogoutEndpoint reads end_session_endpoint out of an OIDC discovery
// document, returning an empty string when the provider does not advertise one.
func providerLogoutEndpoint(provider *oidc.Provider) string {
var claims struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
}
if err := provider.Claims(&claims); err != nil {
return ""
}
return claims.EndSessionEndpoint
}
// logoutConfiguration returns what is needed to end a session at the identity
// provider: the discovered endpoint and the client it would be requested for.
func (s *Server) logoutConfiguration() (endpoint, clientID string) {
s.authMu.RLock()
defer s.authMu.RUnlock()
if s.oauth != nil {
clientID = s.oauth.ClientID
}
return s.endSession, clientID
}
func (s *Server) oidcConfiguration() (*oauth2.Config, *oidc.IDTokenVerifier) {
s.authMu.RLock()
defer s.authMu.RUnlock()

View file

@ -0,0 +1,91 @@
package server
import (
"errors"
"net/http"
"strings"
"github.com/danyel/go-loose/internal/store"
)
const maxPermissionLabel = 100
// listPermissions serves the whole catalog. Every management session may read it,
// because the role editor needs it to render its checkboxes.
func (s *Server) listPermissions(w http.ResponseWriter, r *http.Request) {
catalog, err := s.store.ListPermissions(r.Context())
if err != nil {
s.internalError(w, "list permissions", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"permissions": catalog})
}
// createPermission adds a name to the shared catalog so that roles can grant it.
//
// The catalog is installation-wide, so this is restricted to a system
// administrator or a holder of tenants.manage. Note what the new name does and
// does not do: it becomes grantable and is reported through /connect/userinfo,
// but nothing in the console checks for it, so it cannot unlock console
// behaviour. The response says so, and the UI repeats it.
func (s *Server) createPermission(w http.ResponseWriter, r *http.Request) {
var request struct {
Name string `json:"name"`
Label string `json:"label"`
Description string `json:"description"`
}
if !decodeJSON(w, r, &request) {
return
}
name := strings.TrimSpace(request.Name)
if !store.ValidPermissionName(name) {
writeError(w, http.StatusBadRequest,
"name must be lowercase words separated by dots, for example reports.export")
return
}
label := strings.TrimSpace(request.Label)
if label == "" || len(label) > maxPermissionLabel {
writeError(w, http.StatusBadRequest, "label must be between 1 and 100 characters")
return
}
description := strings.TrimSpace(request.Description)
if len(description) > 500 {
writeError(w, http.StatusBadRequest, "description must be 500 characters or fewer")
return
}
created, err := s.store.CreatePermission(
r.Context(), claimsFrom(r).UserID, name, label, description)
if err != nil {
switch {
case errors.Is(err, store.ErrPermissionTaken):
writeError(w, http.StatusConflict, "this permission already exists")
case errors.Is(err, store.ErrUnknownPermission):
writeError(w, http.StatusBadRequest, err.Error())
default:
s.handleStoreError(w, "create permission", err)
}
return
}
writeJSON(w, http.StatusCreated, created)
}
// deletePermission removes a catalog entry that no role grants.
func (s *Server) deletePermission(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !store.ValidPermissionName(name) {
writeError(w, http.StatusBadRequest, "unknown permission name")
return
}
if err := s.store.DeletePermission(r.Context(), claimsFrom(r).UserID, name); err != nil {
switch {
case errors.Is(err, store.ErrPermissionInUse):
writeError(w, http.StatusConflict, "remove this permission from every role before deleting it")
case errors.Is(err, store.ErrPermissionSystem):
writeError(w, http.StatusConflict, "built-in permissions cannot be deleted")
default:
s.handleStoreError(w, "delete permission", err)
}
return
}
w.WriteHeader(http.StatusNoContent)
}

View file

@ -128,16 +128,36 @@ func (s *Server) avatar(w http.ResponseWriter, r *http.Request) {
func (s *Server) profileResponse(profile store.Profile) profileResponse {
return profileResponse{
ID: profile.ID, Email: profile.Email, DisplayName: profile.DisplayName,
AvatarURL: s.avatarURL(profile.AvatarKey), Memberships: profile.Memberships,
AvatarURL: s.avatarPath(profile.AvatarKey), Memberships: profile.Memberships,
IsSystemAdministrator: profile.IsSystemAdministrator, NameCustomized: profile.NameCustomized,
CreatedAt: profile.CreatedAt, LastLoginAt: profile.LastLoginAt,
ProfileUpdatedAt: profile.ProfileUpdatedAt, AvatarUpdatedAt: profile.AvatarUpdatedAt,
}
}
func (s *Server) avatarURL(key *string) string {
// avatarPath returns the same-origin path of a stored profile picture.
//
// The picture is served by this application, so a page of this application must
// reference it by a root-relative path. An absolute URL has to be built from
// cfg.BaseURL, and that value is routinely absent, stale, or aimed at a canonical
// address the browser is not using: it defaults to http://localhost:8080, which a
// browser asked to load it from any other origin refuses, and this application's
// own Content-Security-Policy allows images from 'self' only. A relative path is
// correct by construction in every deployment.
func (s *Server) avatarPath(key *string) string {
if key == nil || *key == "" {
return ""
}
return s.cfg.BaseURL + "/api/v1/avatars/" + *key
return "/api/v1/avatars/" + *key
}
// avatarURL is the absolute form, for the clients that read a picture from another
// origin: /connect/userinfo is consumed by applications such as go-tell, which
// cannot resolve a relative path against their own host.
func (s *Server) avatarURL(r *http.Request, key *string) string {
path := s.avatarPath(key)
if path == "" {
return ""
}
return s.absoluteURL(r, path)
}

View file

@ -9,7 +9,6 @@ import (
"image/png"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
@ -32,23 +31,44 @@ func profileRequest(t *testing.T, body string) *http.Request {
return request.WithContext(context.WithValue(request.Context(), claimsKey, claims))
}
func TestAvatarURLIsAbsoluteAndOmittedWithoutKey(t *testing.T) {
// The console reads its own picture through avatarPath, so it must be a relative
// path: the configured base URL is absent or stale often enough that building an
// absolute link from it yields an address the browser refuses to load.
func TestAvatarPathIsRelativeAndOmittedWithoutKey(t *testing.T) {
server := profileTestServer()
if got := server.avatarURL(nil); got != "" {
t.Fatalf("avatarURL(nil) = %q, want empty", got)
if got := server.avatarPath(nil); got != "" {
t.Fatalf("avatarPath(nil) = %q, want empty", got)
}
empty := ""
if got := server.avatarURL(&empty); got != "" {
t.Fatalf("avatarURL(empty) = %q, want empty", got)
if got := server.avatarPath(&empty); got != "" {
t.Fatalf("avatarPath(empty) = %q, want empty", got)
}
key := "k"
got := server.avatarURL(&key)
if want := "https://auth.example.test/api/v1/avatars/k"; got != want {
if got, want := server.avatarPath(&key), "/api/v1/avatars/k"; got != want {
t.Fatalf("avatarPath() = %q, want %q", got, want)
}
// A base URL left at its default must not leak into a page's markup.
server.cfg.BaseURL = "http://localhost:8080"
if got := server.avatarPath(&key); got != "/api/v1/avatars/k" {
t.Fatalf("avatarPath() with default base URL = %q", got)
}
}
// Another origin, such as go-tell reading /connect/userinfo, cannot resolve a
// relative path, so that form follows the request rather than the configuration.
func TestAvatarURLFollowsTheRequestNotTheConfiguredBase(t *testing.T) {
server := profileTestServer()
key := "k"
request := httptest.NewRequest(http.MethodGet, "https://nmbs.auth.example.test/connect/userinfo", nil)
if got, want := server.avatarURL(request, &key), "https://nmbs.auth.example.test/api/v1/avatars/k"; got != want {
t.Fatalf("avatarURL() = %q, want %q", got, want)
}
parsed, err := url.Parse(got)
if err != nil || parsed.Path != "/api/v1/avatars/k" {
t.Fatalf("avatar url = %q, err = %v", got, err)
if got := server.avatarURL(request, nil); got != "" {
t.Fatalf("avatarURL(nil) = %q, want empty", got)
}
// Without a host to follow, the configured base is the only thing left.
if got, want := server.avatarURL(&http.Request{}, &key), "https://auth.example.test/api/v1/avatars/k"; got != want {
t.Fatalf("avatarURL() without a request host = %q, want %q", got, want)
}
}
@ -58,7 +78,8 @@ func TestAvatarURLIsAbsoluteAndOmittedWithoutKey(t *testing.T) {
func TestClientUserExposesRoleCapabilitiesAndPicture(t *testing.T) {
server := profileTestServer()
key := "avatar-key"
payload := server.clientUser(store.ClientUser{
request := httptest.NewRequest(http.MethodGet, "https://auth.example.test/connect/userinfo", nil)
payload := server.clientUser(request, store.ClientUser{
ID: "user-1", Email: "operator@example.test", DisplayName: "Operator",
TenantSlug: "nmbs", Application: "guess", Role: "operator",
Permissions: []string{string(role.ManageKeys), string(role.EditProfile)},
@ -84,7 +105,7 @@ func TestClientUserExposesRoleCapabilitiesAndPicture(t *testing.T) {
// rather than an error.
func TestClientUserWithoutMembershipIsLeastPrivileged(t *testing.T) {
server := profileTestServer()
payload := server.clientUser(store.ClientUser{Role: "user", Permissions: []string{string(role.EditProfile)}})
payload := server.clientUser(nil, store.ClientUser{Role: "user", Permissions: []string{string(role.EditProfile)}})
if payload.AvatarURL != "" {
t.Fatalf("avatar url = %q, want empty", payload.AvatarURL)
}

View file

@ -103,8 +103,10 @@ func TestCreateRoleRejectsMalformedInputBeforeTouchingTheStore(t *testing.T) {
{"uppercase slug", `{"slug":"Release Manager","name":"Role","permissions":[]}`},
{"slug starting with a dash", `{"slug":"-role","name":"Role","permissions":[]}`},
{"empty name", `{"slug":"role","name":" ","permissions":[]}`},
{"unknown permission", `{"slug":"role","name":"Role","permissions":["superpower"]}`},
}
// Permission names are not checked here: the catalog is a table, so an unknown
// name can only be caught by the store, which reports it as a bad request.
// See TestARoleCannotGrantAPermissionThatIsNotInTheCatalog.
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
server := profileTestServer()

View file

@ -1,6 +1,7 @@
package server
import (
"bytes"
"context"
"database/sql"
"encoding/json"
@ -22,7 +23,6 @@ import (
"github.com/danyel/go-loose/internal/config"
"github.com/danyel/go-loose/internal/contract"
"github.com/danyel/go-loose/internal/key"
"github.com/danyel/go-loose/internal/role"
"github.com/danyel/go-loose/internal/secretbox"
"github.com/danyel/go-loose/internal/session"
"github.com/danyel/go-loose/internal/store"
@ -31,12 +31,16 @@ import (
)
type Server struct {
cfg config.Config
store *store.Store
db *sql.DB
sessions *session.Manager
oauth *oauth2.Config
verifier *oidc.IDTokenVerifier
cfg config.Config
store *store.Store
db *sql.DB
sessions *session.Manager
oauth *oauth2.Config
verifier *oidc.IDTokenVerifier
// endSession is the identity provider's logout endpoint, empty when the
// provider does not advertise one. It is what lets a sign-out here also end
// the provider session the other applications in the family share.
endSession string
authMu sync.RWMutex
installed atomic.Bool
httpClient *http.Client
@ -70,7 +74,7 @@ func New(ctx context.Context, cfg config.Config, db *sql.DB, logger *slog.Logger
if decryptErr != nil {
return nil, fmt.Errorf("decrypt installed OIDC secret: %w", decryptErr)
}
if err := server.configureOIDC(ctx, installation.OIDCIssuer, installation.OIDCClientID, secret); err != nil {
if err := server.configureOIDC(ctx, installation.OIDCIssuer, installation.OIDCClientID, secret, installation.OIDCRedirectURL); err != nil {
return nil, err
}
server.installed.Store(true)
@ -80,7 +84,7 @@ func New(ctx context.Context, cfg config.Config, db *sql.DB, logger *slog.Logger
} else if !errors.Is(err, store.ErrNotFound) {
return nil, fmt.Errorf("load installation: %w", err)
} else if cfg.OIDCIssuer != "" && cfg.OIDCClientID != "" && cfg.OIDCClientSecret != "" {
if err := server.configureOIDC(ctx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.OIDCClientSecret); err != nil {
if err := server.configureOIDC(ctx, cfg.OIDCIssuer, cfg.OIDCClientID, cfg.OIDCClientSecret, cfg.OIDCRedirectURL); err != nil {
return nil, err
}
server.installed.Store(true)
@ -139,6 +143,9 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/v1/roles", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createRole))))
mux.Handle("PUT /api/v1/roles/{id}", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.updateRole))))
mux.Handle("DELETE /api/v1/roles/{id}", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.deleteRole))))
mux.Handle("GET /api/v1/permissions", s.requireSession(http.HandlerFunc(s.listPermissions)))
mux.Handle("POST /api/v1/permissions", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createPermission))))
mux.Handle("DELETE /api/v1/permissions/{name}", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.deletePermission))))
return s.recover(s.logRequests(s.securityHeaders(mux)))
}
@ -197,7 +204,8 @@ func (s *Server) openapi(w http.ResponseWriter, _ *http.Request) {
func (s *Server) asset(w http.ResponseWriter, r *http.Request) {
name := path.Base(r.PathValue("name"))
if name != "style.css" && name != "app.js" && name != "profile.js" && name != "roles.js" {
if name != "style.css" && name != "app.js" && name != "profile.js" && name != "roles.js" &&
name != "account.js" {
http.NotFound(w, r)
return
}
@ -210,10 +218,62 @@ func (s *Server) serveEmbedded(w http.ResponseWriter, name string) {
http.Error(w, "asset unavailable", http.StatusInternalServerError)
return
}
body := content
// HTML pages get the theme origin substituted in. It is a placeholder rather than
// a literal because the origin is deployment configuration, not build-time.
//
// With no origin configured the two theme lines are removed outright rather than
// emitted with an empty href. An empty href is a same-origin request to this
// application, which 404s: a broken stylesheet request in the console, and a
// styling bug report that points at the wrong service entirely.
if path.Ext(name) == ".html" {
body = applyThemeOrigin(content, s.cfg.ThemeBaseURL)
}
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(name)))
w.Write(content)
w.Write(body)
}
// themeOriginPlaceholder is substituted into every HTML page. It is deliberately not
// valid markup, so an unsubstituted page is obviously wrong rather than subtly so.
const themeOriginPlaceholder = "__GO_LOOSE_THEME_ORIGIN__"
// applyThemeOrigin substitutes the theme origin into an HTML page, or removes the
// theme lines entirely when none is configured.
func applyThemeOrigin(content []byte, base string) []byte {
// TrimSpace as well as the trailing slash: an environment variable set to spaces
// is a plausible accident, and it would otherwise produce a relative URL that
// resolves against this application and 404s.
origin := strings.TrimRight(strings.TrimSpace(base), "/")
if origin == "" {
return dropThemeLines(content)
}
return bytes.ReplaceAll(content, []byte(themeOriginPlaceholder), []byte(origin))
}
// dropThemeLines removes every line carrying a theme marker attribute. Markers are
// used rather than the placeholder itself so that the <link> and the <script> can both
// be removed: substituting an empty origin into the script URL would leave a module
// import of this application's own /rt/v1/components.js, which does not exist.
func dropThemeLines(content []byte) []byte {
lines := bytes.Split(content, []byte("\n"))
kept := make([][]byte, 0, len(lines))
for _, line := range lines {
if bytes.Contains(line, []byte(themeMarkerContract)) || bytes.Contains(line, []byte(themeMarkerScript)) {
continue
}
kept = append(kept, line)
}
return bytes.Join(kept, []byte("\n"))
}
// Marker attributes on the theme lines, used only to find and remove them.
const (
themeMarkerContract = "data-theme-contract"
themeMarkerScript = "data-bananas-components"
)
func (s *Server) authStart(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("return") != "" || s.requestTenantSlug(r.Host) != "" {
http.NotFound(w, r)
@ -342,12 +402,90 @@ func (s *Server) finishLogin(w http.ResponseWriter, r *http.Request, subject, em
http.Redirect(w, r, target, http.StatusSeeOther)
}
// authLogout ends the session and, where the identity provider supports it, the
// provider session the rest of the family shares.
//
// Three things happen, in this order, and the order matters:
//
// 1. Every hosted-login session the user holds is revoked. Go Loose issues those
// bearer tokens, so this is what actually signs the user out of the tenant
// applications: their tokens stop resolving the moment this runs, without the
// other application being told or having to ask. It happens before the local
// cookie is cleared, because the user id is only available while the session
// still exists.
// 2. The local management cookies are cleared.
// 3. The browser is sent to the identity provider's logout endpoint, so the single
// sign-on session that every application in the family relies on ends too. If
// the provider does not advertise an endpoint, this step is skipped and the user
// lands back on the sign-in page: the local sign-out still happened, and the
// other applications end at their next token check rather than immediately.
func (s *Server) authLogout(w http.ResponseWriter, r *http.Request) {
// Read before clearing. A missing or expired session is not a failure to sign
// out, so the claims are best effort and the sign-out proceeds regardless.
claims, err := s.sessions.Get(r)
if err == nil {
if revoked, revokeErr := s.store.RevokeUserClientSessions(r.Context(), claims.UserID); revokeErr != nil {
s.logger.Error("revoke client sessions on logout", "error", revokeErr)
} else if revoked > 0 {
s.logger.Info("revoked client sessions on logout", "count", revoked, "user", claims.UserID)
}
}
s.sessions.Clear(w)
s.sessions.ClearShared(w, s.cfg.AuthDomain)
if target := s.providerLogoutURL(r); target != "" {
http.Redirect(w, r, target, http.StatusSeeOther)
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
// providerLogoutURL builds the identity provider logout URL, or an empty string when
// this installation has no provider endpoint to send the browser to.
//
// The client id is sent instead of an id_token_hint deliberately. A hint would be
// the precise way to end one particular provider session, but it means keeping a
// live credential in the browser cookie between login and logout, and the cookie has
// a size budget this codebase should not spend on it. Providers accept the client id
// and the post-logout redirect on their own.
func (s *Server) providerLogoutURL(r *http.Request) string {
endpoint, clientID := s.logoutConfiguration()
if endpoint == "" || clientID == "" {
return ""
}
target, err := url.Parse(endpoint)
if err != nil {
return ""
}
query := target.Query()
query.Set("client_id", clientID)
// Bounce back to the sign-in page so the provider has somewhere valid to send
// the browser. An unregistered redirect target is silently ignored by some
// providers, which would leave the user on the provider's own page.
query.Set("post_logout_redirect_uri", s.absoluteURL(r, "/login"))
target.RawQuery = query.Encode()
return target.String()
}
// absoluteURL resolves a local path against the request, honouring the address the
// client actually used so that a tenant host does not receive a link to the apex.
//
// The request wins over cfg.BaseURL. BaseURL is a configuration value that is
// routinely absent or stale -- it defaults to http://localhost:8080 -- so building
// a link from it hands the browser an address it cannot load, and a page served
// under a Content-Security-Policy of 'self' refuses it outright. The request's own
// host is the one address known to be both reachable and same-origin.
func (s *Server) absoluteURL(r *http.Request, path string) string {
if r != nil && r.Host != "" {
return s.requestScheme(r) + "://" + r.Host + path
}
if parsed, err := url.Parse(s.cfg.BaseURL); err == nil && parsed.Host != "" {
return parsed.Scheme + "://" + parsed.Host + path
}
return path
}
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
claims := claimsFrom(r)
allowed, err := s.store.HasConsoleAccess(r.Context(), claims.UserID)
@ -394,18 +532,23 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
s.internalError(w, "check system administrator", err)
return
}
catalog, err := s.store.ListPermissions(r.Context())
if err != nil {
s.internalError(w, "list permissions", err)
return
}
profile, err := s.store.Profile(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "load profile", err)
return
}
for index := range users {
users[index].AvatarURL = s.avatarURL(users[index].AvatarKey)
users[index].AvatarURL = s.avatarPath(users[index].AvatarKey)
}
writeJSON(w, http.StatusOK, map[string]any{
"user": claims, "profile": s.profileResponse(profile),
"tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
"users": users, "pending_users": pendingUsers, "permissions": role.Catalog(),
"users": users, "pending_users": pendingUsers, "permissions": catalog,
"is_system_administrator": isSystemAdministrator,
})
}
@ -431,6 +574,10 @@ func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
}
item, err := s.store.CreateTenant(r.Context(), claimsFrom(r).UserID, request.Slug, request.Name)
if err != nil {
if errors.Is(err, store.ErrTenantSlugTaken) {
writeError(w, http.StatusConflict, "a tenant with this slug already exists")
return
}
s.internalError(w, "create tenant", err)
return
}

View file

@ -0,0 +1,91 @@
package server
import (
"strings"
"testing"
)
// The theme origin is substituted into every HTML page. These tests cover the two
// branches and the failure mode in between, because a wrong result here is invisible
// until a user reports that the theme stopped applying.
const pageWithThemeLines = `<!doctype html>
<html data-theme="tokyo" data-mode="dark">
<head>
<link rel="stylesheet" href="/assets/style.css">
<link rel="stylesheet" href="__GO_LOOSE_THEME_ORIGIN__/rt/v1/contract.css" data-theme-contract>
</head>
<body>
<bananas-theme-selector id="theme"></bananas-theme-selector>
<script type="module" src="__GO_LOOSE_THEME_ORIGIN__/rt/v1/components.js" data-bananas-components></script>
</body>
</html>`
func TestThemeOriginIsSubstituted(t *testing.T) {
got := string(applyThemeOrigin([]byte(pageWithThemeLines), "https://themes.example.com"))
if strings.Contains(got, themeOriginPlaceholder) {
t.Error("the placeholder survived substitution")
}
if !strings.Contains(got, `href="https://themes.example.com/rt/v1/contract.css"`) {
t.Errorf("the contract stylesheet was not rewritten:\n%s", got)
}
if !strings.Contains(got, `src="https://themes.example.com/rt/v1/components.js"`) {
t.Errorf("the component module was not rewritten:\n%s", got)
}
// The application's own stylesheet must be untouched.
if !strings.Contains(got, `href="/assets/style.css"`) {
t.Error("the local stylesheet was rewritten")
}
}
func TestThemeOriginTrailingSlashIsTrimmed(t *testing.T) {
// A trailing slash would produce a double slash, and the service answers 404 for
// "//rt/v1/contract.css" because the route is matched on the clean path.
for _, base := range []string{
"https://themes.example.com/",
"https://themes.example.com///",
} {
got := string(applyThemeOrigin([]byte(pageWithThemeLines), base))
if strings.Contains(got, "com//") {
t.Errorf("base %q produced a doubled slash:\n%s", base, got)
}
}
}
func TestThemeLinesAreDroppedWhenNoOriginIsConfigured(t *testing.T) {
for _, base := range []string{"", " ", "/"} {
got := string(applyThemeOrigin([]byte(pageWithThemeLines), base))
if strings.Contains(got, themeOriginPlaceholder) {
t.Errorf("base %q left the placeholder in place:\n%s", base, got)
}
// An empty href would be a same-origin request to this application, which 404s
// and looks like a styling bug in the wrong service.
if strings.Contains(got, `href="/rt/v1/contract.css"`) {
t.Errorf("base %q emitted an empty contract href:\n%s", base, got)
}
if strings.Contains(got, themeMarkerContract) || strings.Contains(got, themeMarkerScript) {
t.Errorf("base %q left a theme line behind:\n%s", base, got)
}
// The rest of the page must survive.
if !strings.Contains(got, `href="/assets/style.css"`) {
t.Errorf("base %q removed the local stylesheet:\n%s", base, got)
}
if !strings.Contains(got, "<body>") || !strings.Contains(got, "</html>") {
t.Errorf("base %q damaged the page structure:\n%s", base, got)
}
}
}
func TestThemeOriginLeavesNonThemePagesAlone(t *testing.T) {
// A page with no theme lines must come back byte-identical, so that a deployment
// without the contract serving its login and install pages is a no-op.
plain := []byte("<!doctype html>\n<html>\n<body>no theme here</body>\n</html>\n")
if got := applyThemeOrigin(plain, "https://themes.example.com"); string(got) != string(plain) {
t.Errorf("a page without theme lines was modified:\n%s", got)
}
if got := applyThemeOrigin(plain, ""); string(got) != string(plain) {
t.Errorf("a page without theme lines was modified:\n%s", got)
}
}

View file

@ -89,7 +89,7 @@ func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
s.handleStoreError(w, "invite user", err)
return
}
user.AvatarURL = s.avatarURL(user.AvatarKey)
user.AvatarURL = s.avatarPath(user.AvatarKey)
writeJSON(w, http.StatusCreated, user)
}