// 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 }