initial commit

This commit is contained in:
Daniel Noulet 2026-10-01 23:15:57 +02:00
commit 54576d9d33
61 changed files with 5569 additions and 0 deletions

View file

@ -0,0 +1,143 @@
package server
import (
"errors"
"net/http"
"net/url"
"strings"
"time"
"github.com/danyel/go-loose/internal/key"
"github.com/danyel/go-loose/internal/store"
)
func (s *Server) clientAuthorize(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
clientID, redirectURI, state := query.Get("client_id"), query.Get("redirect_uri"), query.Get("state")
if query.Get("response_type") != "code" || clientID == "" || redirectURI == "" || state == "" {
writeError(w, http.StatusBadRequest, "response_type=code, client_id, redirect_uri, and state are required")
return
}
app, err := s.store.ValidateClientRedirect(r.Context(), clientID, redirectURI)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusBadRequest, "unknown client or redirect URI")
return
}
s.internalError(w, "validate client redirect", err)
return
}
if !strings.EqualFold(r.Host, s.tenantAuthHost(app.TenantSlug)) {
http.Redirect(w, r, s.tenantAuthURL(app.TenantSlug, r.URL.RequestURI()), http.StatusTemporaryRedirect)
return
}
claims, err := s.sessions.Get(r)
if err != nil {
returnURL := s.tenantAuthURL(app.TenantSlug, r.URL.RequestURI())
loginQuery := url.Values{"client_id": {clientID}, "return": {returnURL}}
http.Redirect(w, r, "/client/login?"+loginQuery.Encode(), http.StatusSeeOther)
return
}
app, err = s.store.ClientForAuthorization(r.Context(), clientID, redirectURI, claims.UserID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusForbidden, "your account does not have access to this application")
return
}
s.internalError(w, "authorize client user", err)
return
}
code, _, codeHash, err := key.GenerateToken("glc_code_")
if err != nil {
s.internalError(w, "generate authorization code", err)
return
}
if err := s.store.CreateAuthorizationCode(r.Context(), app.ID, claims.UserID, redirectURI, codeHash); err != nil {
s.internalError(w, "store authorization code", err)
return
}
target, _ := url.Parse(redirectURI)
values := target.Query()
values.Set("code", code)
values.Set("state", state)
target.RawQuery = values.Encode()
http.Redirect(w, r, target.String(), http.StatusFound)
}
func (s *Server) clientToken(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
if err := r.ParseForm(); err != nil {
writeError(w, http.StatusBadRequest, "invalid token request")
return
}
clientID, clientSecret, ok := r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="Go Loose token endpoint"`)
writeError(w, http.StatusUnauthorized, "HTTP Basic client authentication required")
return
}
code, redirectURI := r.Form.Get("code"), r.Form.Get("redirect_uri")
if r.Form.Get("grant_type") != "authorization_code" || clientID == "" || clientSecret == "" || code == "" || redirectURI == "" {
writeError(w, http.StatusBadRequest, "authorization_code grant parameters are required")
return
}
token, _, tokenHash, err := key.GenerateToken("glc_session_")
if err != nil {
s.internalError(w, "generate client session", err)
return
}
user, expiresAt, err := s.store.ExchangeAuthorizationCode(
r.Context(), clientID, redirectURI, key.Hash(clientSecret), key.Hash(code), tokenHash,
)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusUnauthorized, "invalid or expired authorization code")
return
}
s.internalError(w, "exchange authorization code", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"access_token": token, "token_type": "Bearer",
"expires_in": int(time.Until(expiresAt).Seconds()), "user": user,
})
}
func (s *Server) clientUserInfo(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "Bearer token required")
return
}
user, err := s.store.ClientUserByToken(r.Context(), key.Hash(token))
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusUnauthorized, "session is invalid or expired")
return
}
s.internalError(w, "load client user", err)
return
}
writeJSON(w, http.StatusOK, user)
}
func (s *Server) clientLogout(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r)
if token == "" {
writeError(w, http.StatusUnauthorized, "Bearer token required")
return
}
if err := s.store.RevokeClientSession(r.Context(), key.Hash(token)); err != nil && !errors.Is(err, store.ErrNotFound) {
s.internalError(w, "revoke client session", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func bearerToken(r *http.Request) string {
value := r.Header.Get("Authorization")
if !strings.HasPrefix(value, "Bearer ") {
return ""
}
return strings.TrimSpace(strings.TrimPrefix(value, "Bearer "))
}

View file

@ -0,0 +1,133 @@
package server
import (
"crypto/subtle"
"html"
"net/http"
"strings"
"github.com/danyel/go-loose/internal/password"
"github.com/danyel/go-loose/internal/secretbox"
"github.com/danyel/go-loose/internal/store"
"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)
return
}
content, err := web.Files.ReadFile("install.html")
if err != nil {
http.Error(w, "installer unavailable", http.StatusInternalServerError)
return
}
csrfToken, err := s.installCSRFToken(w)
if err != nil {
http.Error(w, "could not prepare installer", http.StatusInternalServerError)
return
}
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")
w.Write([]byte(page))
}
func (s *Server) install(w http.ResponseWriter, r *http.Request) {
if s.installed.Load() {
writeError(w, http.StatusConflict, "Go Loose is already installed")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 32<<10)
if err := r.ParseForm(); err != nil {
writeError(w, http.StatusBadRequest, "invalid installer request")
return
}
csrfCookie, cookieErr := r.Cookie("go_loose_install_csrf")
csrfValue := r.Form.Get("csrf_token")
payload, valid := s.sessions.VerifySigned(csrfValue)
if cookieErr != nil || subtle.ConstantTimeCompare([]byte(csrfCookie.Value), []byte(csrfValue)) != 1 ||
!valid || !strings.HasPrefix(payload, "install|") {
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)
return
}
seedDemo := r.Form.Get("seed_demo") == "on"
var demoUsers []store.DemoUser
if seedDemo {
demoUsers, err = buildDemoUsers()
if err != nil {
s.internalError(w, "hash demo credentials", err)
return
}
}
err = s.store.Install(r.Context(), store.Installation{
OIDCIssuer: "https://accounts.google.com", OIDCClientID: clientID,
OIDCSecretCiphertext: ciphertext, SeededDemo: seedDemo,
}, demoUsers)
if err != nil {
s.internalError(w, "complete installation", err)
return
}
s.installed.Store(true)
if err := s.ensureLocalAdministrator(r.Context()); err != nil {
s.internalError(w, "bootstrap local administrator", err)
return
}
http.Redirect(w, r, "/auth/start", http.StatusSeeOther)
}
func buildDemoUsers() ([]store.DemoUser, error) {
specs := []struct {
email, name, password, tenant string
}{
{"interview@nmbs.auth.local", "NMBS Interview", "admin123", "nmbs"},
{"interview@ypto.auth.local", "YPTO Interview", "admin123", "ypto"},
{"reviewer@nmbs.auth.local", "NMBS Reviewer", "Demo-Nmbs-2026!", "nmbs"},
{"reviewer@ypto.auth.local", "YPTO Reviewer", "Demo-Ypto-2026!", "ypto"},
}
result := make([]store.DemoUser, 0, len(specs))
for _, spec := range specs {
hash, err := password.HashDemo(spec.password)
if err != nil {
return nil, err
}
result = append(result, store.DemoUser{
Email: spec.email, DisplayName: spec.name, PasswordHash: hash, TenantSlug: spec.tenant,
})
}
return result, nil
}
func (s *Server) installCSRFToken(w http.ResponseWriter) (string, error) {
state, err := s.sessions.NewState()
if err != nil {
return "", err
}
token := s.sessions.SignedState("install|" + state)
http.SetCookie(w, &http.Cookie{
Name: "go_loose_install_csrf", Value: token, Path: "/install",
HttpOnly: true, Secure: strings.HasPrefix(s.cfg.BaseURL, "https://"),
SameSite: http.SameSiteStrictMode, MaxAge: 900,
})
return token, nil
}

63
internal/server/oidc.go Normal file
View file

@ -0,0 +1,63 @@
package server
import (
"context"
"fmt"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/danyel/go-loose/internal/password"
"golang.org/x/oauth2"
)
func (s *Server) configureOIDC(ctx context.Context, issuer, clientID, clientSecret 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,
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
s.authMu.Lock()
s.oauth = config
s.verifier = provider.Verifier(&oidc.Config{ClientID: clientID})
s.authMu.Unlock()
return nil
}
func (s *Server) oidcConfiguration() (*oauth2.Config, *oidc.IDTokenVerifier) {
s.authMu.RLock()
defer s.authMu.RUnlock()
return s.oauth, s.verifier
}
func (s *Server) hasOIDC() bool {
config, verifier := s.oidcConfiguration()
return config != nil && verifier != nil
}
func (s *Server) ensureLocalAdministrator(ctx context.Context) error {
if !s.cfg.LocalLogin {
return nil
}
dummyHash, err := password.Hash("not-a-real-account-password")
if err != nil {
return fmt.Errorf("hash password timing value: %w", err)
}
s.loginGuard.dummyHash = dummyHash
if s.cfg.LocalAdminPassword == "" {
return nil
}
passwordHash, err := password.Hash(s.cfg.LocalAdminPassword)
if err != nil {
return fmt.Errorf("hash local administrator password: %w", err)
}
if err := s.store.EnsurePasswordUser(
ctx, s.cfg.LocalAdminEmail, "Local Administrator", passwordHash,
s.cfg.BootstrapTenant, s.cfg.BootstrapApp,
); err != nil {
return fmt.Errorf("bootstrap local administrator: %w", err)
}
return nil
}

View file

@ -0,0 +1,22 @@
package server
import (
"net/http/httptest"
"testing"
"github.com/danyel/go-loose/internal/config"
)
func TestOAuthStateCookieDomain(t *testing.T) {
server := &Server{cfg: config.Config{AuthDomain: "auth.local"}}
tenantRequest := httptest.NewRequest("GET", "http://nmbs.auth.local/auth/start", nil)
if domain := server.oauthStateCookieDomain(tenantRequest); domain != "auth.local" {
t.Fatalf("tenant cookie domain = %q, want auth.local", domain)
}
localRequest := httptest.NewRequest("GET", "http://localhost:8080/auth/start", nil)
if domain := server.oauthStateCookieDomain(localRequest); domain != "" {
t.Fatalf("local cookie domain = %q, want host-only cookie", domain)
}
}

View file

@ -0,0 +1,137 @@
package server
import (
"crypto/subtle"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/danyel/go-loose/internal/password"
)
type loginAttempt struct {
failures int
windowStart time.Time
lockedUntil time.Time
}
type loginGuard struct {
mu sync.Mutex
attempts map[string]loginAttempt
dummyHash string
}
func newLoginGuard() *loginGuard {
return &loginGuard{attempts: make(map[string]loginAttempt)}
}
func (g *loginGuard) allowed(key string, now time.Time) bool {
g.mu.Lock()
defer g.mu.Unlock()
attempt := g.attempts[key]
return !now.Before(attempt.lockedUntil)
}
func (g *loginGuard) fail(key string, now time.Time) {
g.mu.Lock()
defer g.mu.Unlock()
attempt := g.attempts[key]
if attempt.windowStart.IsZero() || now.Sub(attempt.windowStart) > 10*time.Minute {
attempt = loginAttempt{windowStart: now}
}
attempt.failures++
if attempt.failures >= 5 {
attempt.lockedUntil = now.Add(15 * time.Minute)
}
g.attempts[key] = attempt
if len(g.attempts) > 10_000 {
for itemKey, item := range g.attempts {
if now.Sub(item.windowStart) > 30*time.Minute && now.After(item.lockedUntil) {
delete(g.attempts, itemKey)
}
}
}
}
func (g *loginGuard) success(key string) {
g.mu.Lock()
delete(g.attempts, key)
g.mu.Unlock()
}
func (s *Server) passwordLogin(w http.ResponseWriter, r *http.Request) {
if !s.installed.Load() {
writeError(w, http.StatusServiceUnavailable, "Go Loose is not installed")
return
}
if !s.cfg.LocalLogin {
http.NotFound(w, r)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
if err := r.ParseForm(); err != nil {
writeError(w, http.StatusBadRequest, "invalid login request")
return
}
csrfCookie, cookieErr := r.Cookie("go_loose_login_csrf")
csrfValue := r.FormValue("csrf_token")
csrfPayload, csrfValid := s.sessions.VerifySigned(csrfValue)
if cookieErr != nil || subtle.ConstantTimeCompare([]byte(csrfCookie.Value), []byte(csrfValue)) != 1 ||
!csrfValid || !strings.HasPrefix(csrfPayload, "login|") {
writeError(w, http.StatusForbidden, "invalid login form; reload the login page")
return
}
email := strings.ToLower(strings.TrimSpace(r.Form.Get("email")))
passwordValue := r.Form.Get("password")
target := "/"
if requested := r.Form.Get("return"); requested != "" {
validated, err := s.validateLoginReturn(r.Context(), requested)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid client login return URL")
return
}
target = validated
}
remoteAddress := remoteIP(r.RemoteAddr)
guardKey := remoteAddress + "|" + email
now := time.Now()
if !s.loginGuard.allowed(guardKey, now) {
s.recordPasswordLogin(r, nil, email, remoteAddress, false, "rate_limited")
writeError(w, http.StatusTooManyRequests, "too many login attempts; try again later")
return
}
user, encoded, err := s.store.PasswordUser(r.Context(), email)
valid := err == nil && password.Verify(encoded, passwordValue)
if err != nil {
password.Verify(s.loginGuard.dummyHash, passwordValue)
}
if !valid {
s.loginGuard.fail(guardKey, now)
var userID *string
if user.ID != "" {
userID = &user.ID
}
s.recordPasswordLogin(r, userID, email, remoteAddress, false, "invalid_credentials")
writeError(w, http.StatusUnauthorized, "invalid email or password")
return
}
s.loginGuard.success(guardKey)
s.recordPasswordLogin(r, &user.ID, user.Email, remoteAddress, true, "authenticated")
s.finishLogin(w, r, "local:"+user.Email, user.Email, user.DisplayName, target, false)
}
func (s *Server) recordPasswordLogin(r *http.Request, userID *string, email, remoteAddress string, successful bool, reason string) {
if err := s.store.RecordLogin(r.Context(), userID, email, remoteAddress, successful, reason); err != nil {
s.logger.Error("record password login", "error", err)
}
}
func remoteIP(address string) string {
host, _, err := net.SplitHostPort(address)
if err == nil {
return host
}
return address
}

View file

@ -0,0 +1,24 @@
package server
import (
"testing"
"time"
)
func TestLoginGuardLocksAfterFiveFailures(t *testing.T) {
guard := newLoginGuard()
now := time.Now()
for range 5 {
if !guard.allowed("address|user@example.com", now) {
t.Fatal("guard locked too early")
}
guard.fail("address|user@example.com", now)
}
if guard.allowed("address|user@example.com", now) {
t.Fatal("guard did not lock after five failures")
}
guard.success("address|user@example.com")
if !guard.allowed("address|user@example.com", now) {
t.Fatal("successful login did not clear failures")
}
}

731
internal/server/server.go Normal file
View file

@ -0,0 +1,731 @@
package server
import (
"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
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); 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); 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("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)
mux.Handle("GET /", s.requireSession(http.HandlerFunc(s.index)))
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))))
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) {
allowed, err := s.store.HasManagementAccess(r.Context(), claimsFrom(r).UserID)
if err != nil {
s.internalError(w, "check management access", err)
return
}
if !allowed {
s.serveEmbedded(w, "waiting-room.html")
return
}
s.serveEmbedded(w, "index.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" {
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
}
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(name)))
w.Write(content)
}
func (s *Server) authStart(w http.ResponseWriter, r *http.Request) {
target := "/"
if requested := r.URL.Query().Get("return"); requested != "" {
validated, err := s.validateLoginReturn(r.Context(), requested)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid client login return URL")
return
}
target = validated
}
state, err := s.newLoginState(target)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not start login")
return
}
oauthConfig, verifier := s.oidcConfiguration()
if oauthConfig == nil || verifier == nil {
if s.cfg.DevLogin {
http.Redirect(w, r, "/auth/dev?state="+url.QueryEscape(state), http.StatusSeeOther)
return
}
writeError(w, http.StatusServiceUnavailable, "OIDC is not configured")
return
}
http.SetCookie(w, &http.Cookie{
Name: "go_loose_oauth_state", Value: state, Path: "/auth/callback", Domain: s.oauthStateCookieDomain(r),
HttpOnly: true, Secure: strings.HasPrefix(s.cfg.BaseURL, "https://"),
SameSite: http.SameSiteLaxMode, MaxAge: 600,
})
http.Redirect(w, r, oauthConfig.AuthCodeURL(state), http.StatusFound)
}
func (s *Server) authCallback(w http.ResponseWriter, r *http.Request) {
stateCookie, cookieErr := r.Cookie("go_loose_oauth_state")
if cookieErr != nil || stateCookie.Value != r.URL.Query().Get("state") {
writeError(w, http.StatusBadRequest, "invalid OIDC state")
return
}
http.SetCookie(w, &http.Cookie{
Name: "go_loose_oauth_state", Value: "", Path: "/auth/callback", Domain: s.oauthStateCookieDomain(r),
HttpOnly: true, Secure: strings.HasPrefix(s.cfg.BaseURL, "https://"), MaxAge: -1,
})
target, err := s.loginTargetFromState(r.URL.Query().Get("state"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid OIDC state")
return
}
oauthConfig, verifier := s.oidcConfiguration()
if oauthConfig == nil || verifier == nil {
writeError(w, http.StatusServiceUnavailable, "OIDC is not configured")
return
}
oauthToken, err := oauthConfig.Exchange(r.Context(), r.URL.Query().Get("code"))
if err != nil {
writeError(w, http.StatusUnauthorized, "OIDC code exchange failed")
return
}
rawIDToken, ok := oauthToken.Extra("id_token").(string)
if !ok {
writeError(w, http.StatusUnauthorized, "identity provider did not return an ID token")
return
}
idToken, err := verifier.Verify(r.Context(), rawIDToken)
if err != nil {
writeError(w, http.StatusUnauthorized, "invalid ID token")
return
}
var claims struct {
Subject string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
}
if err := idToken.Claims(&claims); err != nil || claims.Subject == "" || claims.Email == "" {
writeError(w, http.StatusUnauthorized, "identity claims are incomplete")
return
}
if !s.emailAllowed(claims.Email) {
writeError(w, http.StatusForbidden, "email domain is not allowed")
return
}
if claims.Name == "" {
claims.Name = claims.Email
}
s.finishLogin(w, r, claims.Subject, claims.Email, claims.Name, target, true)
}
func (s *Server) authDev(w http.ResponseWriter, r *http.Request) {
if !s.cfg.DevLogin {
http.NotFound(w, r)
return
}
target, err := s.loginTargetFromState(r.URL.Query().Get("state"))
if r.URL.Query().Get("state") == "" {
target, err = "/", nil
}
if err != nil {
writeError(w, http.StatusBadRequest, "invalid development login state")
return
}
s.finishLogin(w, r, "dev:"+s.cfg.DevUser, s.cfg.DevUser, "Local Administrator", target, false)
}
func (s *Server) finishLogin(w http.ResponseWriter, r *http.Request, subject, email, name, target string, claimSystemAdministrator bool) {
user, err := s.store.UpsertUser(r.Context(), subject, email, name)
if err != nil {
s.logger.Error("upsert user", "error", err)
writeError(w, http.StatusInternalServerError, "login failed")
return
}
if claimSystemAdministrator {
if _, err := s.store.ClaimFirstSystemAdministrator(r.Context(), user.ID); err != nil {
s.logger.Error("claim first system administrator", "error", err)
writeError(w, http.StatusInternalServerError, "login setup failed")
return
}
}
claims := session.Claims{
UserID: user.ID, Email: user.Email, DisplayName: user.DisplayName, Management: target == "/",
}
var sessionErr error
if target == "/" {
sessionErr = s.sessions.Set(w, claims)
} else {
sessionErr = s.sessions.SetShared(w, claims, s.cfg.AuthDomain)
}
if sessionErr != nil {
writeError(w, http.StatusInternalServerError, "could not create session")
return
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
func (s *Server) authLogout(w http.ResponseWriter, r *http.Request) {
s.sessions.Clear(w)
s.sessions.ClearShared(w, s.cfg.AuthDomain)
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) {
claims := claimsFrom(r)
allowed, err := s.store.HasManagementAccess(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "check management access", err)
return
}
if !allowed {
writeError(w, http.StatusForbidden, "waiting for administrator approval")
return
}
tenants, err := s.store.ListTenants(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "list tenants", err)
return
}
applications, err := s.store.ListApplications(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "list applications", err)
return
}
keys, err := s.store.ListAPIKeys(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "list API keys", err)
return
}
contracts, err := s.store.ListContracts(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "list contracts", err)
return
}
users, err := s.store.ListManagedUsers(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "list users", err)
return
}
pendingUsers, err := s.store.ListPendingUsers(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "list pending users", err)
return
}
isSystemAdministrator, err := s.store.IsSystemAdministrator(r.Context(), claims.UserID)
if err != nil {
s.internalError(w, "check system administrator", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"user": claims, "tenants": tenants, "applications": applications, "keys": keys, "contracts": contracts,
"users": users, "pending_users": pendingUsers, "is_system_administrator": isSystemAdministrator,
})
}
func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
isSystemAdministrator, err := s.store.IsSystemAdministrator(r.Context(), claimsFrom(r).UserID)
if err != nil {
s.internalError(w, "check system administrator", err)
return
}
if !isSystemAdministrator {
writeError(w, http.StatusForbidden, "system administrator access required")
return
}
var request struct{ Slug, Name string }
if !decodeJSON(w, r, &request) {
return
}
request.Slug, request.Name = strings.TrimSpace(request.Slug), strings.TrimSpace(request.Name)
if !validSlug(request.Slug) || request.Name == "" {
writeError(w, http.StatusBadRequest, "name and a valid lowercase slug are required")
return
}
item, err := s.store.CreateTenant(r.Context(), claimsFrom(r).UserID, request.Slug, request.Name)
if err != nil {
s.internalError(w, "create tenant", err)
return
}
writeJSON(w, http.StatusCreated, item)
}
func (s *Server) createApplication(w http.ResponseWriter, r *http.Request) {
var request store.Application
if !decodeJSON(w, r, &request) {
return
}
request.Name, request.Slug = strings.TrimSpace(request.Name), strings.TrimSpace(request.Slug)
if request.TenantID == "" || request.Name == "" || !validSlug(request.Slug) {
writeError(w, http.StatusBadRequest, "tenant, name, and a valid lowercase slug are required")
return
}
for i := range request.AllowedHosts {
request.AllowedHosts[i] = strings.ToLower(strings.TrimSpace(request.AllowedHosts[i]))
if strings.ContainsAny(request.AllowedHosts[i], "/:") {
writeError(w, http.StatusBadRequest, "allowed hosts must be hostnames without schemes or ports")
return
}
}
item, err := s.store.CreateApplication(r.Context(), claimsFrom(r).UserID, request)
if err != nil {
s.handleStoreError(w, "create application", err)
return
}
writeJSON(w, http.StatusCreated, item)
}
func (s *Server) createKey(w http.ResponseWriter, r *http.Request) {
var request struct {
ApplicationID string `json:"application_id"`
Name string `json:"name"`
ExpiresAt *time.Time `json:"expires_at"`
}
if !decodeJSON(w, r, &request) {
return
}
request.Name = strings.TrimSpace(request.Name)
if request.ApplicationID == "" || request.Name == "" || (request.ExpiresAt != nil && !request.ExpiresAt.After(time.Now())) {
writeError(w, http.StatusBadRequest, "application, name, and a future expiration are required")
return
}
plain, prefix, hash, err := key.Generate()
if err != nil {
s.internalError(w, "generate API key", err)
return
}
item, err := s.store.CreateAPIKey(r.Context(), claimsFrom(r).UserID, request.ApplicationID, request.Name, prefix, hash, request.ExpiresAt)
if err != nil {
s.handleStoreError(w, "create API key", err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"key": item, "secret": plain})
}
func (s *Server) revokeKey(w http.ResponseWriter, r *http.Request) {
if err := s.store.RevokeAPIKey(r.Context(), claimsFrom(r).UserID, r.PathValue("id")); err != nil {
s.handleStoreError(w, "revoke API key", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) authorize(w http.ResponseWriter, r *http.Request) {
plain := strings.TrimSpace(r.Header.Get("X-API-Key"))
if plain == "" {
authorization := r.Header.Get("Authorization")
if strings.HasPrefix(authorization, "Bearer ") {
plain = strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer "))
}
}
if key.ValidateFormat(plain) != nil {
writeError(w, http.StatusUnauthorized, "a valid X-API-Key header is required")
return
}
var request struct {
Tenant string `json:"tenant"`
Application string `json:"application"`
Method string `json:"method"`
Path string `json:"path"`
Host string `json:"host"`
}
if !decodeJSON(w, r, &request) {
return
}
request.Method = strings.ToUpper(strings.TrimSpace(request.Method))
request.Host = strings.ToLower(strings.TrimSpace(request.Host))
if request.Application == "" || request.Method == "" || !strings.HasPrefix(request.Path, "/") {
writeError(w, http.StatusBadRequest, "application, method, and an absolute path are required")
return
}
result, err := s.store.Authorize(r.Context(), key.Hash(plain), request.Tenant, request.Application, request.Method, request.Path, request.Host)
if err != nil {
s.internalError(w, "authorize request", err)
return
}
if !result.Allowed {
writeJSON(w, http.StatusForbidden, result)
return
}
writeJSON(w, http.StatusOK, result)
}
func (s *Server) createContract(w http.ResponseWriter, r *http.Request) {
var request struct {
ApplicationID string `json:"application_id"`
SourceType string `json:"source_type"`
SourceURL string `json:"source_url"`
Document string `json:"document"`
}
if !decodeJSON(w, r, &request) {
return
}
var content []byte
var sourceURL *string
if request.SourceType == "url" {
parsed, err := contract.ValidateSourceURL(request.SourceURL, s.cfg.AllowPrivateContractURL)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
content, err = s.fetchContract(r.Context(), parsed)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
value := parsed.String()
sourceURL = &value
} else {
content = []byte(request.Document)
if request.SourceURL != "" {
sourceURL = &request.SourceURL
}
}
if len(content) == 0 {
writeError(w, http.StatusBadRequest, "an OpenAPI document or source URL is required")
return
}
document, err := contract.Parse(content)
if err != nil {
writeError(w, http.StatusUnprocessableEntity, err.Error())
return
}
item, err := s.store.SaveContract(r.Context(), claimsFrom(r).UserID, request.ApplicationID, document, sourceURL)
if err != nil {
s.handleStoreError(w, "save contract", err)
return
}
writeJSON(w, http.StatusCreated, item)
}
func (s *Server) fetchContract(ctx context.Context, source *url.URL) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, source.String(), nil)
if err != nil {
return nil, errors.New("could not create contract request")
}
request.Header.Set("Accept", "application/json, application/yaml, text/yaml")
response, err := s.httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("fetch contract: %w", err)
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, fmt.Errorf("contract source returned HTTP %d", response.StatusCode)
}
content, err := io.ReadAll(io.LimitReader(response.Body, 5<<20+1))
if err != nil {
return nil, fmt.Errorf("read contract: %w", err)
}
if len(content) > 5<<20 {
return nil, errors.New("contract exceeds 5 MiB")
}
return content, nil
}
func (s *Server) requireSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.installed.Load() {
http.Redirect(w, r, "/install", http.StatusSeeOther)
return
}
claims, err := s.sessions.Get(r)
if err != nil || !claims.Management {
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/openapi.json" {
writeError(w, http.StatusUnauthorized, "authentication required")
} else {
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), claimsKey, claims)))
})
}
func (s *Server) requireSameOrigin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
source := r.Header.Get("Origin")
if source == "" {
source = r.Header.Get("Referer")
}
sourceURL, sourceErr := url.Parse(source)
baseURL, baseErr := url.Parse(s.cfg.BaseURL)
if sourceErr != nil || baseErr != nil || sourceURL.Scheme != baseURL.Scheme || sourceURL.Host != baseURL.Host {
writeError(w, http.StatusForbidden, "same-origin request required")
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "same-origin")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline' https://unpkg.com; script-src 'self' 'unsafe-inline' https://unpkg.com; connect-src 'self'; img-src 'self' data:; font-src 'self' data:")
next.ServeHTTP(w, r)
})
}
func (s *Server) logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
s.logger.Info("http request", "method", r.Method, "path", r.URL.Path, "duration", time.Since(start))
})
}
func (s *Server) recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
s.logger.Error("panic recovered", "value", recovered)
writeError(w, http.StatusInternalServerError, "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
func (s *Server) emailAllowed(email string) bool {
if len(s.cfg.AllowedEmailDomains) == 0 {
return true
}
parts := strings.Split(strings.ToLower(email), "@")
if len(parts) != 2 {
return false
}
for _, domain := range s.cfg.AllowedEmailDomains {
if parts[1] == domain {
return true
}
}
return false
}
func (s *Server) oauthStateCookieDomain(r *http.Request) string {
host := strings.ToLower(r.Host)
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
host = parsedHost
}
if host == s.cfg.AuthDomain || strings.HasSuffix(host, "."+s.cfg.AuthDomain) {
return s.cfg.AuthDomain
}
return ""
}
func (s *Server) internalError(w http.ResponseWriter, operation string, err error) {
s.logger.Error(operation, "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
}
func (s *Server) handleStoreError(w http.ResponseWriter, operation string, err error) {
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "resource not found or permission denied")
return
}
s.internalError(w, operation, err)
}
func claimsFrom(r *http.Request) session.Claims {
return r.Context().Value(claimsKey).(session.Claims)
}
func validSlug(value string) bool {
if len(value) < 2 || len(value) > 63 || value[0] < 'a' || value[0] > 'z' {
return false
}
for _, character := range value {
if (character < 'a' || character > 'z') && (character < '0' || character > '9') && character != '-' {
return false
}
}
return true
}
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
r.Body = http.MaxBytesReader(w, r.Body, 6<<20)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON request")
return false
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "request must contain one JSON object")
return false
}
return true
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(value); err != nil {
slog.Error("encode response", "error", err)
}
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}

View file

@ -0,0 +1,151 @@
package server
import (
"context"
"encoding/base64"
"errors"
"html"
"net"
"net/http"
"net/url"
"strings"
"github.com/danyel/go-loose/web"
)
func (s *Server) tenantAuthHost(tenantSlug string) string {
host := tenantSlug + "." + s.cfg.AuthDomain
if parsed, err := url.Parse(s.cfg.BaseURL); err == nil && parsed.Port() != "" {
host = net.JoinHostPort(host, parsed.Port())
}
return host
}
func (s *Server) tenantAuthURL(tenantSlug, requestURI string) string {
base, _ := url.Parse(s.cfg.BaseURL)
return base.Scheme + "://" + s.tenantAuthHost(tenantSlug) + requestURI
}
func (s *Server) validateLoginReturn(ctx context.Context, raw string) (string, error) {
target, err := url.Parse(raw)
if err != nil || !target.IsAbs() || target.Path != "/connect/authorize" || target.Fragment != "" || target.User != nil {
return "", errors.New("invalid return URL")
}
base, err := url.Parse(s.cfg.BaseURL)
if err != nil || target.Scheme != base.Scheme {
return "", errors.New("invalid return scheme")
}
clientID, redirectURI := target.Query().Get("client_id"), target.Query().Get("redirect_uri")
app, err := s.store.ValidateClientRedirect(ctx, clientID, redirectURI)
if err != nil || !strings.EqualFold(target.Host, s.tenantAuthHost(app.TenantSlug)) {
return "", errors.New("return URL does not match client tenant")
}
return target.String(), nil
}
func (s *Server) newLoginState(target string) (string, error) {
nonce, err := s.sessions.NewState()
if err != nil {
return "", err
}
payload := nonce + "|" + base64.RawURLEncoding.EncodeToString([]byte(target))
return s.sessions.SignedState(payload), nil
}
func (s *Server) loginTargetFromState(state string) (string, error) {
payload, ok := s.sessions.VerifySigned(state)
if !ok {
return "", errors.New("invalid signed state")
}
parts := strings.SplitN(payload, "|", 2)
if len(parts) != 2 || parts[0] == "" {
return "", errors.New("invalid state payload")
}
decoded, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", errors.New("invalid state target")
}
target := string(decoded)
if target == "/" {
return target, nil
}
return s.validateLoginReturn(context.Background(), target)
}
func (s *Server) renderClientLogin(w http.ResponseWriter, r *http.Request) {
clientID := r.URL.Query().Get("client_id")
returnURL := r.URL.Query().Get("return")
app, err := s.store.ClientByID(r.Context(), clientID)
if err != nil || !strings.EqualFold(r.Host, s.tenantAuthHost(app.TenantSlug)) {
http.NotFound(w, r)
return
}
validatedReturn, err := s.validateLoginReturn(r.Context(), returnURL)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid client login request")
return
}
content, err := web.Files.ReadFile("client-login.html")
if err != nil {
http.Error(w, "login page unavailable", http.StatusInternalServerError)
return
}
loginURL := "/auth/start?return=" + url.QueryEscape(validatedReturn)
csrfToken, err := s.loginCSRFToken(w)
if err != nil {
http.Error(w, "could not prepare login", http.StatusInternalServerError)
return
}
page := strings.NewReplacer(
"{{TENANT_NAME}}", html.EscapeString(app.TenantName),
"{{APPLICATION_NAME}}", html.EscapeString(app.Name),
"{{LOGIN_URL}}", html.EscapeString(loginURL),
"{{RETURN_URL}}", html.EscapeString(validatedReturn),
"{{CSRF_TOKEN}}", html.EscapeString(csrfToken),
"{{LOCAL_STYLE}}", hiddenStyle(!s.cfg.LocalLogin),
"{{SSO_STYLE}}", hiddenStyle(!s.hasOIDC()),
).Replace(string(content))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(page))
}
func (s *Server) renderManagementLogin(w http.ResponseWriter, _ *http.Request) {
content, err := web.Files.ReadFile("login.html")
if err != nil {
http.Error(w, "login page unavailable", http.StatusInternalServerError)
return
}
csrfToken, err := s.loginCSRFToken(w)
if err != nil {
http.Error(w, "could not prepare login", http.StatusInternalServerError)
return
}
page := strings.NewReplacer(
"{{CSRF_TOKEN}}", html.EscapeString(csrfToken),
"{{LOCAL_STYLE}}", hiddenStyle(!s.cfg.LocalLogin),
"{{SSO_STYLE}}", hiddenStyle(!s.hasOIDC()),
).Replace(string(content))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(page))
}
func (s *Server) loginCSRFToken(w http.ResponseWriter) (string, error) {
state, err := s.sessions.NewState()
if err != nil {
return "", err
}
token := s.sessions.SignedState("login|" + state)
http.SetCookie(w, &http.Cookie{
Name: "go_loose_login_csrf", Value: token, Path: "/auth/password",
HttpOnly: true, Secure: strings.HasPrefix(s.cfg.BaseURL, "https://"),
SameSite: http.SameSiteStrictMode, MaxAge: 600,
})
return token, nil
}
func hiddenStyle(hidden bool) string {
if hidden {
return "display:none"
}
return ""
}

View file

@ -0,0 +1,136 @@
package server
import (
"net/http"
"net/mail"
"net/url"
"strings"
"github.com/danyel/go-loose/internal/key"
"github.com/danyel/go-loose/internal/password"
)
func (s *Server) configureClient(w http.ResponseWriter, r *http.Request) {
var request struct {
RedirectURIs []string `json:"redirect_uris"`
}
if !decodeJSON(w, r, &request) {
return
}
if len(request.RedirectURIs) == 0 {
writeError(w, http.StatusBadRequest, "at least one redirect URI is required")
return
}
seen := make(map[string]bool)
for index, raw := range request.RedirectURIs {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.Fragment != "" || parsed.User != nil {
writeError(w, http.StatusBadRequest, "redirect URIs must be absolute URLs without fragments or credentials")
return
}
localHTTP := (s.cfg.DevLogin || s.cfg.LocalLogin) && parsed.Scheme == "http" &&
(parsed.Hostname() == "localhost" || parsed.Hostname() == "127.0.0.1")
if parsed.Scheme != "https" && !localHTTP {
writeError(w, http.StatusBadRequest, "redirect URIs require HTTPS except localhost in development")
return
}
request.RedirectURIs[index] = parsed.String()
if seen[parsed.String()] {
writeError(w, http.StatusBadRequest, "redirect URIs must be unique")
return
}
seen[parsed.String()] = true
}
secret, _, hash, err := key.GenerateToken("glc_secret_")
if err != nil {
s.internalError(w, "generate client secret", err)
return
}
app, err := s.store.ConfigureClient(r.Context(), claimsFrom(r).UserID, r.PathValue("id"), request.RedirectURIs, hash)
if err != nil {
s.handleStoreError(w, "configure client login", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"application": app, "client_secret": secret})
}
func (s *Server) inviteUser(w http.ResponseWriter, r *http.Request) {
var request struct {
TenantID string `json:"tenant_id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
Password string `json:"password"`
}
if !decodeJSON(w, r, &request) {
return
}
address, err := mail.ParseAddress(strings.TrimSpace(request.Email))
if err != nil || address.Address != request.Email {
writeError(w, http.StatusBadRequest, "a valid plain email address is required")
return
}
request.Email = strings.ToLower(address.Address)
request.DisplayName = strings.TrimSpace(request.DisplayName)
if request.DisplayName == "" {
request.DisplayName = request.Email
}
if request.Role != "admin" && request.Role != "viewer" {
writeError(w, http.StatusBadRequest, "role must be admin or viewer")
return
}
passwordHash, err := password.Hash(request.Password)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
user, err := s.store.InviteUser(r.Context(), claimsFrom(r).UserID, request.TenantID, request.Email, request.DisplayName, request.Role, passwordHash)
if err != nil {
s.handleStoreError(w, "invite user", err)
return
}
writeJSON(w, http.StatusCreated, user)
}
func (s *Server) setUserAccess(w http.ResponseWriter, r *http.Request) {
var request struct {
TenantID string `json:"tenant_id"`
Role string `json:"role"`
ApplicationIDs []string `json:"application_ids"`
}
if !decodeJSON(w, r, &request) {
return
}
if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" {
writeError(w, http.StatusBadRequest, "invalid role")
return
}
if err := s.store.SetUserAccess(r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"), request.Role, request.ApplicationIDs); err != nil {
s.handleStoreError(w, "update user access", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (s *Server) approveUser(w http.ResponseWriter, r *http.Request) {
var request struct {
TenantID string `json:"tenant_id"`
Role string `json:"role"`
ApplicationIDs []string `json:"application_ids"`
}
if !decodeJSON(w, r, &request) {
return
}
if request.Role != "owner" && request.Role != "admin" && request.Role != "viewer" {
writeError(w, http.StatusBadRequest, "invalid role")
return
}
if err := s.store.ApprovePendingUser(
r.Context(), claimsFrom(r).UserID, request.TenantID, r.PathValue("id"),
request.Role, request.ApplicationIDs,
); err != nil {
s.handleStoreError(w, "approve pending user", err)
return
}
w.WriteHeader(http.StatusNoContent)
}