deploy configuration
This commit is contained in:
parent
9ba3c47d82
commit
dac02c61c3
45 changed files with 1405 additions and 582 deletions
142
internal/golooseauth/browser.go
Normal file
142
internal/golooseauth/browser.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
// 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
|
||||
}
|
||||
150
internal/golooseauth/browser_test.go
Normal file
150
internal/golooseauth/browser_test.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package golooseauth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
goloose "github.com/danyel/go-loose/client"
|
||||
|
||||
"github.com/danyel/go-tell/internal/config"
|
||||
)
|
||||
|
||||
func TestNewWithoutCredentialsIsDisabled(t *testing.T) {
|
||||
auth, err := New(config.GoLooseConfig{Tenant: "tell", AuthDomain: "auth.dev", AppDomain: "tell.dev"})
|
||||
if err != nil || auth != nil {
|
||||
t.Fatalf("New() = %#v, %v; want a disabled client", auth, err)
|
||||
}
|
||||
auth, err = New(config.GoLooseConfig{ClientID: "client"})
|
||||
if err != nil || auth != nil {
|
||||
t.Fatalf("New() with a half-configured client = %#v, %v; want a disabled client", auth, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsInvalidDomains(t *testing.T) {
|
||||
cases := map[string]config.GoLooseConfig{
|
||||
"tenant with a dot": {Tenant: "tell.dev", AuthDomain: "auth.dev", AppDomain: "tell.dev", ClientID: "c", ClientSecret: "s"},
|
||||
"empty tenant": {Tenant: " ", AuthDomain: "auth.dev", AppDomain: "tell.dev", ClientID: "c", ClientSecret: "s"},
|
||||
"empty auth domain": {Tenant: "tell", ClientID: "c", ClientSecret: "s"},
|
||||
"empty app domain": {Tenant: "tell", AuthDomain: "auth.dev", ClientID: "c", ClientSecret: "s"},
|
||||
}
|
||||
for name, cfg := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := New(cfg); err == nil {
|
||||
t.Fatal("New() error = nil, want a configuration error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewBuildsSingleHostBrowserClient(t *testing.T) {
|
||||
auth, err := New(config.GoLooseConfig{
|
||||
Tenant: " Tell ", AuthDomain: ".auth.dev.", AppDomain: "Tell.Dev",
|
||||
ClientID: "client", ClientSecret: "secret",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if auth.Tenant() != "https://tell.auth.dev" {
|
||||
t.Fatalf("Tenant() = %q", auth.Tenant())
|
||||
}
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
auth.LoginHandler(recorder, httptest.NewRequest(http.MethodGet, LoginPath, nil))
|
||||
if recorder.Code != http.StatusFound {
|
||||
t.Fatalf("login status = %d, body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
target, err := url.Parse(recorder.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if target.Scheme+"://"+target.Host != "https://tell.auth.dev" || target.Path != "/connect/authorize" {
|
||||
t.Fatalf("unexpected login target: %s", target)
|
||||
}
|
||||
if target.Query().Get("client_id") != "client" ||
|
||||
target.Query().Get("redirect_uri") != "https://tell.dev"+CallbackPath ||
|
||||
target.Query().Get("state") == "" {
|
||||
t.Fatalf("unexpected login query: %s", target.RawQuery)
|
||||
}
|
||||
cookies := recorder.Result().Cookies()
|
||||
if len(cookies) != 1 || cookies[0].Name == SessionCookie || !cookies[0].HttpOnly {
|
||||
t.Fatalf("expected an HTTP-only state cookie scoped to the callback, got %#v", cookies)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionUserResolvesCookie(t *testing.T) {
|
||||
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer session-token" {
|
||||
http.Error(w, "missing bearer token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"id":"user-1","email":"owner@tell.dev","display_name":"Owner","tenant_slug":"tell"}`))
|
||||
}))
|
||||
defer identity.Close()
|
||||
|
||||
auth := testAuth(t, identity)
|
||||
request := httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil)
|
||||
if _, ok := auth.SessionUser(request); ok {
|
||||
t.Fatal("SessionUser() without a cookie = true, want false")
|
||||
}
|
||||
if _, ok := auth.SessionUser(httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil)); ok {
|
||||
t.Fatal("SessionUser() without a cookie = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionUserRejectsRejectedSession(t *testing.T) {
|
||||
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
http.Error(w, "no such session", http.StatusUnauthorized)
|
||||
}))
|
||||
defer identity.Close()
|
||||
|
||||
auth := testAuth(t, identity)
|
||||
request := httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil)
|
||||
request.AddCookie(&http.Cookie{Name: SessionCookie, Value: "expired"})
|
||||
if user, ok := auth.SessionUser(request); ok {
|
||||
t.Fatalf("SessionUser() with a rejected session = %#v, want no user", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionUserReturnsIdentity(t *testing.T) {
|
||||
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"id":"user-1","email":"owner@tell.dev","display_name":"Owner","tenant_slug":"tell"}`))
|
||||
}))
|
||||
defer identity.Close()
|
||||
|
||||
auth := testAuth(t, identity)
|
||||
request := httptest.NewRequest(http.MethodPut, "https://tell.dev/api/content/post", nil)
|
||||
request.AddCookie(&http.Cookie{Name: SessionCookie, Value: "session-token"})
|
||||
user, ok := auth.SessionUser(request)
|
||||
if !ok {
|
||||
t.Fatal("SessionUser() = false, want the session user")
|
||||
}
|
||||
if user.Email != "owner@tell.dev" || user.DisplayName != "Owner" || user.TenantSlug != "tell" {
|
||||
t.Fatalf("unexpected user: %#v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilAuthHasNoSession(t *testing.T) {
|
||||
var auth *Auth
|
||||
if user, ok := auth.SessionUser(httptest.NewRequest(http.MethodGet, "/", nil)); ok {
|
||||
t.Fatalf("SessionUser() on a disabled client = %#v, want no user", user)
|
||||
}
|
||||
}
|
||||
|
||||
// testAuth wires an Auth to a local identity server, which is what the client
|
||||
// would talk to once the base URL points somewhere reachable in a test.
|
||||
func testAuth(t *testing.T, identity *httptest.Server) *Auth {
|
||||
t.Helper()
|
||||
browser, err := goloose.NewBrowserAuth(goloose.BrowserAuthConfig{
|
||||
BaseURL: identity.URL, ClientID: "client", ClientSecret: "secret",
|
||||
RedirectURL: "https://tell.dev" + CallbackPath, LoginPath: LoginPath,
|
||||
CookieName: SessionCookie, HTTPClient: identity.Client(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Auth{BrowserAuth: browser, baseURL: identity.URL, httpClient: identity.Client()}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue