package server
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"mime"
"net"
"net/http"
"net/url"
"path"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"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/secretbox"
"github.com/danyel/go-loose/internal/session"
"github.com/danyel/go-loose/internal/store"
"github.com/danyel/go-loose/web"
"golang.org/x/oauth2"
)
type Server struct {
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
logger *slog.Logger
loginGuard *loginGuard
}
type contextKey string
const claimsKey contextKey = "claims"
func New(ctx context.Context, cfg config.Config, db *sql.DB, logger *slog.Logger) (*Server, error) {
server := &Server{
cfg: cfg, store: store.New(db), db: db, logger: logger,
sessions: session.New(cfg.SessionSecret, strings.HasPrefix(cfg.BaseURL, "https://")),
loginGuard: newLoginGuard(),
}
server.httpClient = &http.Client{
Timeout: cfg.HTTPTimeout,
CheckRedirect: func(request *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("too many contract source redirects")
}
_, err := contract.ValidateSourceURL(request.URL.String(), cfg.AllowPrivateContractURL)
return err
},
}
installation, err := server.store.Installation(ctx)
if err == nil {
secret, decryptErr := secretbox.Decrypt(cfg.SessionSecret, installation.OIDCSecretCiphertext)
if decryptErr != nil {
return nil, fmt.Errorf("decrypt installed OIDC secret: %w", decryptErr)
}
if err := server.configureOIDC(ctx, installation.OIDCIssuer, installation.OIDCClientID, secret, installation.OIDCRedirectURL); err != nil {
return nil, err
}
server.installed.Store(true)
if err := server.ensureLocalAdministrator(ctx); err != nil {
return nil, err
}
} 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, cfg.OIDCRedirectURL); err != nil {
return nil, err
}
server.installed.Store(true)
if err := server.ensureLocalAdministrator(ctx); err != nil {
return nil, err
}
} else if cfg.LocalLogin && cfg.LocalAdminPassword != "" {
server.installed.Store(true)
if err := server.ensureLocalAdministrator(ctx); err != nil {
return nil, err
}
}
return server, nil
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.health)
mux.HandleFunc("GET /install", s.installPage)
mux.HandleFunc("POST /install", s.install)
mux.HandleFunc("GET /login", s.loginPage)
mux.HandleFunc("GET /auth/start", s.authStart)
mux.HandleFunc("GET /auth/callback", s.authCallback)
mux.HandleFunc("GET /auth/dev", s.authDev)
mux.HandleFunc("POST /auth/logout", s.authLogout)
mux.HandleFunc("POST /auth/password", s.passwordLogin)
mux.HandleFunc("GET /assets/{name}", s.asset)
mux.HandleFunc("GET /api/v1/avatars/{key}", s.avatar)
mux.HandleFunc("POST /api/v1/authorize", s.authorize)
mux.HandleFunc("GET /connect/authorize", s.clientAuthorize)
mux.HandleFunc("POST /connect/token", s.clientToken)
mux.HandleFunc("GET /connect/userinfo", s.clientUserInfo)
mux.HandleFunc("POST /connect/logout", s.clientLogout)
mux.HandleFunc("GET /client/login", s.clientLoginPage)
// {$} matches only "/". The unanchored pattern "GET /" is a catch-all, so a
// browser favicon request would redirect to /install and rotate its CSRF cookie.
mux.Handle("GET /{$}", s.requireSession(http.HandlerFunc(s.index)))
mux.Handle("GET /profile", s.requireSession(http.HandlerFunc(s.profilePage)))
mux.Handle("GET /roles", s.requireSession(http.HandlerFunc(s.rolesPage)))
mux.Handle("GET /api/v1/profile", s.requireSession(http.HandlerFunc(s.profile)))
mux.Handle("PUT /api/v1/profile", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.updateProfile))))
mux.Handle("GET /docs", s.requireSession(http.HandlerFunc(s.docs)))
mux.Handle("GET /openapi.json", s.requireSession(http.HandlerFunc(s.openapi)))
mux.Handle("GET /api/v1/dashboard", s.requireSession(http.HandlerFunc(s.dashboard)))
mux.Handle("POST /api/v1/tenants", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createTenant))))
mux.Handle("POST /api/v1/applications", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createApplication))))
mux.Handle("POST /api/v1/keys", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createKey))))
mux.Handle("POST /api/v1/keys/{id}/revoke", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.revokeKey))))
mux.Handle("POST /api/v1/contracts", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.createContract))))
mux.Handle("POST /api/v1/applications/{id}/client-config", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.configureClient))))
mux.Handle("POST /api/v1/users", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.inviteUser))))
mux.Handle("PUT /api/v1/users/{id}/access", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.setUserAccess))))
mux.Handle("POST /api/v1/users/{id}/approve", s.requireSession(s.requireSameOrigin(http.HandlerFunc(s.approveUser))))
mux.Handle("GET /api/v1/roles", s.requireSession(http.HandlerFunc(s.listRoles)))
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)))
}
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := s.db.PingContext(ctx); err != nil {
writeError(w, http.StatusServiceUnavailable, "database unavailable")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) {
if !s.installed.Load() {
http.Redirect(w, r, "/install", http.StatusSeeOther)
return
}
s.renderManagementLogin(w, r)
}
func (s *Server) clientLoginPage(w http.ResponseWriter, r *http.Request) {
s.renderClientLogin(w, r)
}
func (s *Server) index(w http.ResponseWriter, r *http.Request) {
claims := claimsFrom(r)
console, err := s.store.HasConsoleAccess(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "check console access", err)
return
}
if console {
s.serveEmbedded(w, "index.html")
return
}
member, err := s.store.HasMembership(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "check membership", err)
return
}
if member {
http.Redirect(w, r, "/profile", http.StatusSeeOther)
return
}
s.serveEmbedded(w, "waiting-room.html")
}
func (s *Server) docs(w http.ResponseWriter, _ *http.Request) {
s.serveEmbedded(w, "docs.html")
}
func (s *Server) openapi(w http.ResponseWriter, _ *http.Request) {
s.serveEmbedded(w, "openapi.json")
}
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" &&
name != "account.js" {
http.NotFound(w, r)
return
}
s.serveEmbedded(w, name)
}
func (s *Server) serveEmbedded(w http.ResponseWriter, name string) {
content, err := web.Files.ReadFile(name)
if err != nil {
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(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 and the