~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
575 lines · 16.2 KB Raw History
package gatewayhttp

import (
	"cmp"
	"context"
	"crypto/subtle"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"sync"
	"time"
	"unicode"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/contract/gateway"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/httpjson"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/opt"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/supervise"
)

const (
	WebSessionCookie = "swc_session"

	WebOAuthStateCookie = "swc_oauth_state"

	WebOAuthCallbackPath = "/oauth/callback"

	DefaultCookieMaxAge = 30 * 24 * time.Hour

	DefaultClientMetadataCache = time.Minute

	OAuthStateMaxAge = 10 * time.Minute

	MaxIdentifierLength = 256
)

const (
	cookieSameSiteStrict = "Strict"
	cookieSameSiteLax    = "Lax"
)

const clientMetadataRefreshTimeout = 30 * time.Second

type (
	WebUser              = gateway.WebUser
	WebCaller            = gateway.WebCaller
	SignInStart          = gateway.SignInStart
	SignInCompleteParams = gateway.SignInCompleteParams
	SignInGrant          = gateway.SignInGrant
	SignOutResult        = gateway.SignOutResult
	RecordRef            = gateway.RecordRef
	Record               = gateway.Record
	Write                = gateway.Write
	WebAccountAgent      = gateway.WebAccountAgent
	WebSignIn            = gateway.WebSignIn
)

func CookieValue(header, name string) string {
	for part := range strings.SplitSeq(header, ";") {
		before, after, ok := strings.Cut(part, "=")
		if !ok {
			continue
		}
		if strings.TrimSpace(before) == name {
			return strings.TrimSpace(after)
		}
	}
	return ""
}

func serializeCookie(name, value string, maxAge int, sameSite, path string, secure bool) string {
	out := fmt.Sprintf("%s=%s; Path=%s; Max-Age=%d; HttpOnly; SameSite=%s", name, value, path, maxAge, sameSite)
	if secure {
		out += "; Secure"
	}
	return out
}

type RequestAuthOptions struct {
	SignIn WebSignIn

	PublicOrigin string

	CookieName string

	Log *slog.Logger
}

type RequestAuth struct {
	signIn       WebSignIn
	publicOrigin string
	cookieName   string
	log          *slog.Logger
}

func NewRequestAuth(opts RequestAuthOptions) (*RequestAuth, error) {
	if opts.SignIn == nil {
		return nil, errors.New("gatewayhttp: request auth has no sign-in backend")
	}
	opts.CookieName = cmp.Or(opts.CookieName, WebSessionCookie)
	log := opts.Log
	if log == nil {
		log = slog.Default()
	}
	return &RequestAuth{
		signIn:       opts.SignIn,
		publicOrigin: strings.TrimRight(opts.PublicOrigin, "/"),
		cookieName:   opts.CookieName,
		log:          log,
	}, nil
}

var safeMethods = map[string]bool{
	http.MethodGet:     true,
	http.MethodHead:    true,
	http.MethodOptions: true,
}

func (a *RequestAuth) originAllowed(r *http.Request) bool {
	if safeMethods[strings.ToUpper(r.Method)] {
		return true
	}
	origin := r.Header.Get("Origin")
	if origin == "" {
		return false
	}
	if a.publicOrigin != "" {
		return origin == a.publicOrigin
	}
	u, err := url.Parse(origin)
	if err != nil {
		return false
	}
	return u.Host == r.Host
}

func (a *RequestAuth) Authenticate(r *http.Request) (*WebCaller, error) {
	token := CookieValue(r.Header.Get("Cookie"), a.cookieName)
	if token == "" {
		return nil, nil
	}
	if !a.originAllowed(r) {
		a.log.Warn("web_origin_refused", "method", r.Method, "origin", r.Header.Get("Origin"))
		return nil, nil
	}
	user, err := a.signIn.Identify(r.Context(), token)
	if err != nil {
		return nil, err
	}
	if user == nil {
		return nil, nil
	}
	return &WebCaller{WebUser: *user, Token: token}, nil
}

type OAuthOptions struct {
	SignIn WebSignIn

	Auth *RequestAuth

	PublicOrigin string

	CookieName string

	CookieMaxAge time.Duration

	ClientMetadataPath string

	ClientMetadataCache time.Duration

	MaxBodyBytes int64

	PostSignInURL string

	OnSignedIn func(context.Context, *WebUser) error

	OnSignedOut func(context.Context, *WebUser) error

	Tasks *supervise.Group

	Log *slog.Logger
}

type OAuth struct {
	opts               OAuthOptions
	cookieName         string
	cookieMaxAge       int
	publicOrigin       string
	secure             bool
	maxBody            int64
	postSignInURL      string
	metadataPath       string
	metadataCache      time.Duration
	stateCookie        func(value string, maxAge int) string
	sessionCookie      func(value string, maxAge int) string
	auth               *RequestAuth
	mu                 sync.Mutex
	metadata           json.RawMessage
	metadataAt         time.Time
	metadataLoaded     bool
	metadataRefreshing bool
}

func NewOAuth(opts OAuthOptions) (*OAuth, error) {
	if opts.SignIn == nil {
		return nil, errors.New("gatewayhttp: OAuth has no sign-in backend")
	}
	if opts.Tasks == nil {
		return nil, errors.New("gatewayhttp: OAuth has no task group")
	}
	opts.CookieName = cmp.Or(opts.CookieName, WebSessionCookie)
	opts.CookieMaxAge = opt.Positive(opts.CookieMaxAge, DefaultCookieMaxAge)
	opts.ClientMetadataPath = cmp.Or(opts.ClientMetadataPath, "/oauth-client-metadata.json")
	opts.ClientMetadataCache = opt.Positive(opts.ClientMetadataCache, DefaultClientMetadataCache)
	opts.MaxBodyBytes = opt.Positive(opts.MaxBodyBytes, DefaultMaxBodyBytes)
	publicOrigin := strings.TrimRight(opts.PublicOrigin, "/")
	log := opts.Log
	if log == nil {
		log = slog.Default()
	}
	opts.Log = log
	auth := opts.Auth
	if auth == nil {
		var err error
		auth, err = NewRequestAuth(RequestAuthOptions{
			SignIn:       opts.SignIn,
			PublicOrigin: publicOrigin,
			CookieName:   opts.CookieName,
			Log:          log,
		})
		if err != nil {
			return nil, err
		}
	}
	o := &OAuth{
		opts:          opts,
		cookieName:    opts.CookieName,
		cookieMaxAge:  int(opts.CookieMaxAge / time.Second),
		publicOrigin:  publicOrigin,
		secure:        strings.HasPrefix(publicOrigin, "https://"),
		maxBody:       opts.MaxBodyBytes,
		postSignInURL: strings.TrimSpace(opts.PostSignInURL),
		metadataPath:  opts.ClientMetadataPath,
		metadataCache: opts.ClientMetadataCache,
		auth:          auth,
	}
	o.stateCookie = func(value string, maxAge int) string {
		return serializeCookie(WebOAuthStateCookie, value, maxAge, cookieSameSiteLax, "/oauth", o.secure)
	}
	o.sessionCookie = func(value string, maxAge int) string {
		return serializeCookie(o.cookieName, value, maxAge, cookieSameSiteStrict, "/", o.secure)
	}
	return o, nil
}

func (o *OAuth) Auth() *RequestAuth { return o.auth }

func (o *OAuth) SignIn() WebSignIn { return o.opts.SignIn }

func (o *OAuth) CallerFor(who *WebCaller) ClusterCaller {
	if who == nil {
		return nil
	}
	agent := o.opts.SignIn.AgentFor(who)
	return NewCaller(who.DID, func(ctx context.Context, audience, lxm string) (string, error) {
		return agent.GetServiceAuth(ctx, audience, lxm)
	})
}

func (o *OAuth) RequestCaller(r *http.Request) (ClusterCaller, error) {
	who, err := o.auth.Authenticate(r)
	if err != nil || who == nil {
		return nil, err
	}
	return o.CallerFor(who), nil
}

func (o *OAuth) Register(mux *http.ServeMux) {
	mux.HandleFunc("GET "+o.metadataPath, o.clientMetadata)
	mux.HandleFunc("POST /oauth/start", o.start)
	mux.HandleFunc("GET "+WebOAuthCallbackPath, o.callback)
	mux.HandleFunc("GET /session", o.sessionGET)
	mux.HandleFunc("DELETE /session", o.sessionDELETE)
}

func (o *OAuth) Handler() http.Handler {
	mux := http.NewServeMux()
	o.Register(mux)
	return mux
}

func (o *OAuth) clientMetadata(w http.ResponseWriter, r *http.Request) {
	o.mu.Lock()
	if !o.metadataLoaded {
		o.mu.Unlock()
		doc, err := o.opts.SignIn.ClientMetadata(r.Context())
		if err != nil {
			o.opts.Log.Error("client_metadata_unavailable", "err", err)
			httpjson.Write(w, http.StatusServiceUnavailable, struct {
				Error string `json:"error"`
			}{"client metadata is not available from the vault"})

			return
		}
		o.mu.Lock()
		o.metadata = append(o.metadata[:0], doc...)
		o.metadataAt = time.Now()
		o.metadataLoaded = true
		cached := append(json.RawMessage(nil), o.metadata...)
		o.mu.Unlock()
		o.writeClientMetadata(w, cached)
		return
	}
	if !o.metadataRefreshing && time.Since(o.metadataAt) >= o.metadataRefreshAfter() {
		o.metadataRefreshing = true
		o.opts.Tasks.Go("gatewayhttp: client metadata refresh", func(ctx context.Context) error {
			o.refreshClientMetadata(ctx)
			return nil
		})
	}
	cached := append(json.RawMessage(nil), o.metadata...)
	o.mu.Unlock()
	o.writeClientMetadata(w, cached)
}

func (o *OAuth) metadataRefreshAfter() time.Duration {
	after := max(o.metadataCache/2, time.Second)
	return after
}

func (o *OAuth) refreshClientMetadata(ctx context.Context) {
	timeout := o.metadataCache
	if timeout <= 0 || timeout > clientMetadataRefreshTimeout {
		timeout = clientMetadataRefreshTimeout
	}
	ctx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()
	doc, err := o.opts.SignIn.ClientMetadata(ctx)
	o.mu.Lock()
	defer o.mu.Unlock()
	o.metadataRefreshing = false
	if err != nil {
		o.opts.Log.Warn("client_metadata_refresh_failed", "err", err)
		o.metadataAt = time.Now()
		return
	}
	o.metadata = append(o.metadata[:0], doc...)
	o.metadataAt = time.Now()
	o.metadataLoaded = true
}

func (o *OAuth) writeClientMetadata(w http.ResponseWriter, doc json.RawMessage) {
	w.Header().Set("Content-Type", "application/json")
	w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", int(o.metadataCache/time.Second)))
	_, _ = w.Write(doc)
}

func (o *OAuth) start(w http.ResponseWriter, r *http.Request) {
	if o.publicOrigin == "" {
		httpjson.Write(w, http.StatusServiceUnavailable, struct {
			Error string `json:"error"`
		}{"this deployment has no public-origin, so it cannot sign anyone in"})

		return
	}
	if declared, err := parseContentLength(r); err == nil && declared > o.maxBody {
		httpjson.Write(w, http.StatusRequestEntityTooLarge, struct {
			Error string `json:"error"`
		}{"body too large"})

		return
	}
	raw, err := io.ReadAll(io.LimitReader(r.Body, o.maxBody+1))
	if err != nil || int64(len(raw)) > o.maxBody {
		httpjson.Write(w, http.StatusRequestEntityTooLarge, struct {
			Error string `json:"error"`
		}{"body too large"})

		return
	}
	var body struct {
		Handle any `json:"handle"`
	}
	_ = json.Unmarshal(raw, &body)
	identifier := ""
	if handle, ok := body.Handle.(string); ok {
		identifier = strings.TrimPrefix(strings.TrimSpace(handle), "@")
	}
	if identifier == "" || len(identifier) > MaxIdentifierLength || strings.ContainsFunc(identifier, unicode.IsSpace) {
		httpjson.Write(w, http.StatusBadRequest, struct {
			Error string `json:"error"`
		}{"expected a handle or a DID"})

		return
	}
	started, err := o.opts.SignIn.Start(r.Context(), identifier, o.publicOrigin+WebOAuthCallbackPath)
	if err != nil {
		o.opts.Log.Error("web_sign_in_start_failed", "identifier", identifier, "err", err)
		httpjson.Write(w, http.StatusBadGateway, struct {
			Error string `json:"error"`
		}{"could not start the sign-in: " + err.Error()})

		return
	}
	o.opts.Log.Info("web_sign_in_started", "identifier", identifier)
	w.Header().Set("Set-Cookie", o.stateCookie(started.State, int(OAuthStateMaxAge/time.Second)))
	w.Header().Set("Cache-Control", "no-store")
	httpjson.Write(w, http.StatusOK, struct {
		AuthorizationURL string `json:"authorizationUrl"`
	}{started.AuthorizationURL})

}

type signInCompletion struct {
	SignedIn bool   `json:"signedIn"`
	DID      string `json:"did,omitempty"`
	Handle   string `json:"handle,omitempty"`
}

func (o *OAuth) finish(w http.ResponseWriter, r *http.Request, status int, completion *signInCompletion, errorMessage string, extraCookies ...string) {
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Add("Set-Cookie", o.stateCookie("", 0))
	for _, cookie := range extraCookies {
		w.Header().Add("Set-Cookie", cookie)
	}
	if o.postSignInURL != "" {
		http.Redirect(w, r, o.completionRedirect(errorMessage), http.StatusFound)
		return
	}
	if errorMessage != "" {
		httpjson.Write(w, status, struct {
			Error string `json:"error"`
		}{errorMessage})

		return
	}
	if completion == nil {
		completion = &signInCompletion{SignedIn: true}
	}
	httpjson.Write(w, status, completion)
}

func (o *OAuth) completionRedirect(errorMessage string) string {
	if errorMessage == "" {
		return o.postSignInURL
	}
	parsed, err := url.Parse(o.postSignInURL)
	if err != nil {
		return o.postSignInURL + "?" + url.Values{"error": {errorMessage}}.Encode()
	}
	values := parsed.Query()
	values.Set("error", errorMessage)
	parsed.RawQuery = values.Encode()
	return parsed.String()
}

func (o *OAuth) callback(w http.ResponseWriter, r *http.Request) {
	query := r.URL.Query()
	if denied := query.Get("error"); denied != "" {
		description := query.Get("error_description")
		description = cmp.Or(description, denied)
		o.finish(w, r, http.StatusUnauthorized, nil, description)
		return
	}
	state := query.Get("state")
	code := query.Get("code")
	if state == "" || code == "" {
		o.finish(w, r, http.StatusBadRequest, nil, "the authorization server returned no code")
		return
	}
	presented := CookieValue(r.Header.Get("Cookie"), WebOAuthStateCookie)
	if subtle.ConstantTimeCompare([]byte(presented), []byte(state)) != 1 {
		o.opts.Log.Warn("web_sign_in_state_not_bound")
		o.finish(w, r, http.StatusBadRequest, nil, "this sign-in was not started in this browser; start it again")
		return
	}
	grant, err := o.opts.SignIn.Complete(r.Context(), SignInCompleteParams{
		State: state, Code: code, ISS: query.Get("iss"),
	})
	if err != nil {
		o.opts.Log.Error("web_sign_in_failed", "err", err)
		o.finish(w, r, http.StatusBadGateway, nil, "sign-in failed: "+err.Error())
		return
	}
	user := &WebUser{DID: grant.DID, Handle: grant.Handle, PDS: grant.PDS}
	if o.opts.OnSignedIn != nil {
		if err := o.opts.OnSignedIn(r.Context(), user); err != nil {
			o.opts.Log.Error("web_signed_in_hook_failed", "did", grant.DID, "err", err)
			if _, signOutErr := o.opts.SignIn.SignOut(r.Context(), &WebCaller{WebUser: *user, Token: grant.Token}); signOutErr != nil {
				o.opts.Log.Error("web_signed_in_hook_sign_out_failed", "did", grant.DID, "err", signOutErr)
			}
			o.finish(w, r, http.StatusForbidden, nil, "this account was not admitted")
			return
		}
	}
	o.opts.Log.Info("web_signed_in", "did", grant.DID, "handle", grant.Handle)
	o.finish(w, r, http.StatusOK, &signInCompletion{SignedIn: true, DID: grant.DID, Handle: grant.Handle}, "", o.sessionCookie(grant.Token, o.cookieMaxAge))
}

func (o *OAuth) authenticate(w http.ResponseWriter, r *http.Request) (*WebCaller, bool) {
	caller, err := o.auth.Authenticate(r)
	if err != nil {
		o.opts.Log.Error("web_identify_failed", "err", err)
		httpjson.Write(w, http.StatusServiceUnavailable, struct {
			Error string `json:"error"`
		}{"the vault is not answering"})

		return nil, false
	}
	if caller == nil {
		return nil, true
	}
	return caller, true
}

func (o *OAuth) sessionGET(w http.ResponseWriter, r *http.Request) {
	caller, ok := o.authenticate(w, r)
	if !ok {
		return
	}
	if caller == nil {
		writeFailure(w, &apiFailure{Status: http.StatusUnauthorized, Error: "Unauthorized", Message: "sign in"})
		return
	}
	w.Header().Set("Cache-Control", "no-store")
	httpjson.Write(w, http.StatusOK, struct {
		DID    string `json:"did"`
		Handle string `json:"handle"`
	}{caller.DID, caller.Handle})

}

func (o *OAuth) sessionDELETE(w http.ResponseWriter, r *http.Request) {
	caller, ok := o.authenticate(w, r)
	if !ok {
		return
	}
	if caller == nil {
		w.Header().Set("Set-Cookie", o.sessionCookie("", 0))
		writeFailure(w, &apiFailure{Status: http.StatusUnauthorized, Error: "Unauthorized", Message: "sign in"})
		return
	}
	result, err := o.opts.SignIn.SignOut(r.Context(), caller)
	if err != nil {
		o.opts.Log.Error("web_sign_out_failed", "did", caller.DID, "err", err)
		w.Header().Set("Set-Cookie", o.sessionCookie("", 0))
		writeFailure(w, &apiFailure{Status: http.StatusBadGateway, Error: "could not sign out"})
		return
	}
	if o.opts.OnSignedOut != nil {
		if err := o.opts.OnSignedOut(r.Context(), &WebUser{DID: caller.DID, Handle: caller.Handle, PDS: caller.PDS}); err != nil {
			o.opts.Log.Warn("web_signed_out_hook_failed", "did", caller.DID, "err", err)
		}
	}
	o.opts.Log.Info("web_signed_out", "did", caller.DID, "revoked", result.Revoked, "revokeError", result.RevokeError)
	w.Header().Set("Set-Cookie", o.sessionCookie("", 0))
	httpjson.Write(w, http.StatusOK, struct {
		Revoked     bool   `json:"revoked"`
		RevokeError string `json:"revokeError,omitempty"`
	}{result.Revoked, result.RevokeError})

}

func parseContentLength(r *http.Request) (int64, error) {
	raw := r.Header.Get("Content-Length")
	if raw == "" {
		return 0, nil
	}
	return strconv.ParseInt(raw, 10, 64)
}