// Package golooseauth builds the Go Loose hosted-login client used by the CMS. package golooseauth import ( "context" "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. // 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 { defaultAuth *goloose.BrowserAuth urpiAuth *goloose.BrowserAuth devAuth *goloose.BrowserAuth baseURL string urpiBase string devBase 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 defaultAuth, 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 } urpiBase := "https://" + tenant + ".auth.urpi.be" urpiAuth, _ := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{ BaseURL: urpiBase, ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, RedirectURL: "https://tell.urpi.be" + CallbackPath, AfterLoginURL: "/", AfterLogoutURL: "/", LoginPath: LoginPath, CookieName: SessionCookie, CookieSecure: true, HTTPClient: httpClient, }) devBase := "https://" + tenant + ".auth.dev" devAuth, _ := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{ BaseURL: devBase, ClientID: cfg.ClientID, ClientSecret: cfg.ClientSecret, RedirectURL: "https://tell.dev" + CallbackPath, AfterLoginURL: "/", AfterLogoutURL: "/", LoginPath: LoginPath, CookieName: SessionCookie, CookieSecure: true, HTTPClient: httpClient, }) return &Auth{ defaultAuth: defaultAuth, urpiAuth: urpiAuth, devAuth: devAuth, baseURL: baseURL, urpiBase: urpiBase, devBase: devBase, httpClient: httpClient, }, nil } func (a *Auth) forRequest(r *http.Request) (*goloose.BrowserAuth, string) { if r != nil { host := strings.ToLower(r.Host) if fwd := r.Header.Get("X-Forwarded-Host"); fwd != "" { host = strings.ToLower(fwd) } if strings.Contains(host, "urpi.be") && a.urpiAuth != nil { return a.urpiAuth, a.urpiBase } if (strings.Contains(host, "dev") || strings.Contains(host, "localhost")) && a.devAuth != nil { return a.devAuth, a.devBase } } if a.defaultAuth != nil { return a.defaultAuth, a.baseURL } if a.devAuth != nil { return a.devAuth, a.devBase } return a.urpiAuth, a.urpiBase } // Tenant reports the Go Loose tenant that owns the login. func (a *Auth) Tenant() string { return a.baseURL } func (a *Auth) LoginHandler(w http.ResponseWriter, r *http.Request) { auth, _ := a.forRequest(r) if auth != nil { auth.LoginHandler(w, r) return } http.Error(w, "auth provider unavailable", http.StatusServiceUnavailable) } func (a *Auth) CallbackHandler(w http.ResponseWriter, r *http.Request) { auth, _ := a.forRequest(r) if auth != nil { auth.CallbackHandler(w, r) return } http.Error(w, "auth provider unavailable", http.StatusServiceUnavailable) } func (a *Auth) LogoutHandler(w http.ResponseWriter, r *http.Request) { auth, _ := a.forRequest(r) if auth != nil { auth.LogoutHandler(w, r) return } http.Error(w, "auth provider unavailable", http.StatusServiceUnavailable) } // 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 } _, targetBase := a.forRequest(r) if user, ok := a.checkUser(r.Context(), targetBase, cookie.Value); ok { return user, true } if targetBase != a.baseURL && a.baseURL != "" { if user, ok := a.checkUser(r.Context(), a.baseURL, cookie.Value); ok { return user, true } } if targetBase != a.urpiBase && a.urpiBase != "" { if user, ok := a.checkUser(r.Context(), a.urpiBase, cookie.Value); ok { return user, true } } if targetBase != a.devBase && a.devBase != "" { if user, ok := a.checkUser(r.Context(), a.devBase, cookie.Value); ok { return user, true } } return goloose.User{}, false } func (a *Auth) checkUser(ctx context.Context, baseURL, token string) (goloose.User, bool) { request, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/connect/userinfo", nil) if err != nil { return goloose.User{}, false } request.Header.Set("Authorization", "Bearer "+token) 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 }