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