go-tell/internal/golooseauth/browser.go
2026-10-03 04:41:31 +02:00

142 lines
4.7 KiB
Go

// Package golooseauth builds the Go Loose hosted-login client used by the CMS.
package golooseauth
import (
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
goloose "github.com/danyel/go-loose/client"
"github.com/danyel/go-tell/internal/config"
)
const (
// SessionCookie holds the Go Loose session token after a successful login.
SessionCookie = "go_loose_user_session"
// LoginPath starts the browser login flow.
LoginPath = "/api/auth/login"
// CallbackPath is the redirect URI registered with Go Loose.
CallbackPath = "/api/auth/callback"
// LogoutPath ends the browser session, locally and at the identity provider.
LogoutPath = "/api/auth/logout"
)
// Auth is the Go Loose browser client plus the session lookup the API needs.
// The login, callback, and logout handlers come from the Go Loose client; this
// type only adds a non-redirecting way to turn a request into a user, because
// the client middleware answers anonymous requests with a redirect and the CMS
// has to answer its own API with a status code.
type Auth struct {
*goloose.BrowserAuth
baseURL string
httpClient *http.Client
}
// New returns the browser client for the configured Go Loose application, or a
// nil client when no credentials are set, which leaves the CMS with its other
// identity sources. ClientID and ClientSecret are the values Go Loose shows once
// when a client application is configured.
func New(cfg config.GoLooseConfig) (*Auth, error) {
if !cfg.Enabled() {
return nil, nil
}
tenant := domain(cfg.Tenant)
if tenant == "" || strings.Contains(tenant, ".") {
return nil, fmt.Errorf("GO_LOOSE_TENANT must be a single lowercase tenant slug")
}
authDomain := domain(cfg.AuthDomain)
if authDomain == "" {
return nil, fmt.Errorf("GO_LOOSE_AUTH_DOMAIN is required when client credentials are configured")
}
appDomain := domain(cfg.AppDomain)
if appDomain == "" {
return nil, fmt.Errorf("GO_LOOSE_APP_DOMAIN is required when client credentials are configured")
}
httpClient, err := client(cfg.CAFile)
if err != nil {
return nil, err
}
baseURL := "https://" + tenant + "." + authDomain
browser, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
BaseURL: baseURL,
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: "https://" + appDomain + CallbackPath,
AfterLoginURL: "/",
AfterLogoutURL: "/",
LoginPath: LoginPath,
CookieName: SessionCookie,
CookieSecure: true,
HTTPClient: httpClient,
})
if err != nil {
return nil, err
}
return &Auth{BrowserAuth: browser, baseURL: baseURL, httpClient: httpClient}, nil
}
// Tenant reports the Go Loose tenant that owns the login.
func (a *Auth) Tenant() string { return a.baseURL }
// SessionUser resolves the Go Loose session cookie of a request into a user.
// Requests without a cookie, with a rejected session, or without a reachable
// identity provider are simply not signed in.
func (a *Auth) SessionUser(r *http.Request) (goloose.User, bool) {
if a == nil {
return goloose.User{}, false
}
cookie, err := r.Cookie(SessionCookie)
if err != nil || cookie.Value == "" {
return goloose.User{}, false
}
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, a.baseURL+"/connect/userinfo", nil)
if err != nil {
return goloose.User{}, false
}
request.Header.Set("Authorization", "Bearer "+cookie.Value)
response, err := a.httpClient.Do(request)
if err != nil {
return goloose.User{}, false
}
defer response.Body.Close()
var user goloose.User
if response.StatusCode != http.StatusOK ||
json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&user) != nil {
return goloose.User{}, false
}
return user, true
}
// domain normalizes a configured host or tenant slug.
func domain(value string) string {
return strings.Trim(strings.ToLower(strings.TrimSpace(value)), ".")
}
// client returns the HTTP client used to talk to Go Loose, trusting the extra
// certificate authorities in caFile when one is configured. It is needed while
// the identity domain is served by an internal certificate authority.
func client(caFile string) (*http.Client, error) {
transport := http.DefaultTransport.(*http.Transport).Clone()
if caFile != "" {
pem, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("read Go Loose CA: %w", err)
}
pool, err := x509.SystemCertPool()
if err != nil || pool == nil {
pool = x509.NewCertPool()
}
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("parse Go Loose CA %s", caFile)
}
transport.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
}
return &http.Client{Timeout: 5 * time.Second, Transport: transport}, nil
}