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:
parent
3fdc8cb489
commit
c7c81fb65c
43 changed files with 2248 additions and 298 deletions
366
internal/server/account_test.go
Normal file
366
internal/server/account_test.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue