2026-10-03 04:41:31 +02:00
|
|
|
// Package golooseauth builds the Go Loose hosted-login client used by the CMS.
|
|
|
|
|
package golooseauth
|
|
|
|
|
|
|
|
|
|
import (
|
2026-10-07 15:10:10 +02:00
|
|
|
"context"
|
2026-10-03 04:41:31 +02:00
|
|
|
"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"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-07 15:10:10 +02:00
|
|
|
// 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.
|
2026-10-03 04:41:31 +02:00
|
|
|
// 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 {
|
2026-10-07 15:10:10 +02:00
|
|
|
defaultAuth *goloose.BrowserAuth
|
|
|
|
|
urpiAuth *goloose.BrowserAuth
|
|
|
|
|
devAuth *goloose.BrowserAuth
|
|
|
|
|
baseURL string
|
|
|
|
|
urpiBase string
|
|
|
|
|
devBase string
|
|
|
|
|
httpClient *http.Client
|
2026-10-03 04:41:31 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-10-07 15:10:10 +02:00
|
|
|
defaultAuth, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
|
2026-10-03 04:41:31 +02:00
|
|
|
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
|
|
|
|
|
}
|
2026-10-07 15:10:10 +02:00
|
|
|
|
|
|
|
|
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
|
2026-10-03 04:41:31 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Tenant reports the Go Loose tenant that owns the login.
|
|
|
|
|
func (a *Auth) Tenant() string { return a.baseURL }
|
|
|
|
|
|
2026-10-07 15:10:10 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 04:41:31 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-10-07 15:10:10 +02:00
|
|
|
_, 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)
|
2026-10-03 04:41:31 +02:00
|
|
|
if err != nil {
|
|
|
|
|
return goloose.User{}, false
|
|
|
|
|
}
|
2026-10-07 15:10:10 +02:00
|
|
|
request.Header.Set("Authorization", "Bearer "+token)
|
2026-10-03 04:41:31 +02:00
|
|
|
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
|
|
|
|
|
}
|