~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
506 lines · 12.4 KB Raw History
package oauthclient

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"slices"
	"sort"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"github.com/bluesky-social/indigo/atproto/atcrypto"
	"github.com/bluesky-social/indigo/atproto/identity"
	"github.com/bluesky-social/indigo/atproto/syntax"

	atprotooauth "github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/oauth"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/pdsclient"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/xrpc"
)

const (
	AccessSkew = 30 * time.Second

	DefaultHTTPTimeout = 30 * time.Second

	ServiceAuthNSID = xrpc.ServiceAuthNSID
)

type Options struct {
	DID string

	Handle string

	RepoDID string

	PDSURL string

	ClientID string

	Scopes []string

	Directory identity.Directory

	HTTPClient *http.Client

	Store SessionStore

	Refresher TokenRefresher

	Log *slog.Logger
}

type Session struct {
	mu sync.Mutex

	data SessionData

	key atcrypto.PrivateKeyExportable

	client *http.Client

	store SessionStore

	refresher TokenRefresher

	log *slog.Logger

	did string

	hostURL string

	scopeAbsentLogged bool

	refreshToken atomic.Pointer[string]
}

var _ pdsclient.TokenSource = (*Session)(nil)

var _ pdsclient.DPoPSource = (*Session)(nil)

func Open(ctx context.Context, opts Options) (*Session, error) {
	if opts.Store == nil {
		return nil, errors.New("oauthclient: no oauth session store")
	}
	sessions, err := opts.Store.List(ctx)
	if err != nil {
		return nil, err
	}
	if len(sessions) == 0 {
		return nil, ErrNoSession
	}
	did, err := sessionDID(ctx, opts, sessions)
	if err != nil {
		return nil, err
	}
	session, ok := selectSession(sessions, did)
	if !ok {
		return nil, fmt.Errorf("oauthclient: no oauth session stored for %s", did)
	}
	if session.HostURL == "" {
		return nil, fmt.Errorf("%w: the oauth session names no PDS", ErrInvalidSession)
	}
	if opts.RepoDID != "" && opts.RepoDID != did {
		return nil, fmt.Errorf("%w: the oauth session is for %s, not %s", ErrInvalidSession, did, opts.RepoDID)
	}
	if opts.PDSURL != "" && strings.TrimRight(opts.PDSURL, "/") != strings.TrimRight(session.HostURL, "/") {
		return nil, fmt.Errorf("%w: the oauth session names PDS %s, not %s", ErrInvalidSession, session.HostURL, opts.PDSURL)
	}
	instance, err := New(session, opts)
	if err != nil {
		return nil, fmt.Errorf("%w: %v", ErrInvalidSession, err)
	}
	_ = instance.EnsureEndpoints(ctx)
	if session.ClientID == "" && strings.TrimSpace(opts.ClientID) != "" {
		_ = instance.persist(ctx)
	}
	if !AccessTokenLive(session.AccessToken) {
		if err := instance.refreshStale(ctx); err != nil {
			return nil, fmt.Errorf("oauthclient: refreshing the oauth session for %s: %w", did, err)
		}
	}
	return instance, nil
}

func (s *Session) EnsureEndpoints(ctx context.Context) error {
	if s == nil {
		return errors.New("oauthclient: no oauth session")
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.data.AuthServerTokenEndpoint != "" {
		return nil
	}
	if err := s.ensureEndpointsLocked(ctx); err != nil {
		return err
	}
	return s.persistLocked(ctx)
}

func New(data SessionData, opts Options) (*Session, error) {
	did, err := syntax.ParseDID(data.AccountDID)
	if err != nil {
		return nil, fmt.Errorf("oauthclient: the oauth session account DID: %w", err)
	}
	if data.SessionID == "" {
		data.SessionID = SessionID(did.String(), strings.TrimRight(data.HostURL, "/"))
	}
	if data.HostURL == "" {
		return nil, errors.New("oauthclient: the oauth session names no PDS")
	}
	if data.ClientID == "" {
		data.ClientID = strings.TrimSpace(opts.ClientID)
	}
	host, err := url.Parse(data.HostURL)
	if err != nil || host.Host == "" || (host.Scheme != "http" && host.Scheme != "https") {
		return nil, fmt.Errorf("oauthclient: the oauth session PDS %q is not an absolute http or https URL", data.HostURL)
	}
	key, err := parseDPoPKey(data.DPoPPrivateKeyMultibase)
	if err != nil {
		return nil, err
	}
	client := opts.HTTPClient
	if client == nil {
		client = &http.Client{Timeout: DefaultHTTPTimeout}
	}
	log := opts.Log
	if log == nil {
		log = slog.New(slog.DiscardHandler)
	}
	session := &Session{
		data:      data,
		key:       key,
		client:    client,
		store:     opts.Store,
		refresher: opts.Refresher,
		log:       log,
		did:       did.String(),
		hostURL:   strings.TrimRight(data.HostURL, "/"),
	}
	session.refreshToken.Store(&data.RefreshToken)
	return session, nil
}

func sessionDID(ctx context.Context, opts Options, sessions []SessionData) (string, error) {
	if opts.DID != "" {
		did, err := syntax.ParseDID(opts.DID)
		if err != nil {
			return "", fmt.Errorf("oauthclient: oauth identity DID: %w", err)
		}
		return did.String(), nil
	}
	if opts.Handle != "" {
		if opts.Directory == nil {
			return "", errors.New("oauthclient: no identity directory resolves the oauth handle")
		}
		atid, err := syntax.ParseAtIdentifier(opts.Handle)
		if err != nil {
			return "", fmt.Errorf("oauthclient: oauth handle %q: %w", opts.Handle, err)
		}
		ident, err := opts.Directory.Lookup(ctx, atid)
		if err != nil {
			return "", fmt.Errorf("oauthclient: resolving the oauth handle %q: %w", opts.Handle, err)
		}
		return ident.DID.String(), nil
	}
	dids := map[string]bool{}
	for _, session := range sessions {
		if session.AccountDID != "" {
			dids[session.AccountDID] = true
		}
	}
	if len(dids) != 1 {
		return "", errors.New("oauthclient: oauth identity mode names no DID or handle")
	}
	for did := range dids {
		return did, nil
	}
	return "", errors.New("oauthclient: oauth identity mode names no DID or handle")
}

func selectSession(sessions []SessionData, did string) (SessionData, bool) {
	prefix := did + "/"
	keys := []string{}
	byKey := map[string]SessionData{}
	for _, session := range sessions {
		key := SessionKey(session.AccountDID, session.SessionID)
		if strings.HasPrefix(key, prefix) {
			keys = append(keys, key)
			byKey[key] = session
		}
	}
	sort.Strings(keys)
	if len(keys) == 0 {
		return SessionData{}, false
	}
	return byKey[keys[0]], true
}

func (s *Session) DID() string {
	if s == nil {
		return ""
	}
	return s.did
}

func (s *Session) HostURL() string {
	if s == nil {
		return ""
	}
	return s.hostURL
}

func (s *Session) AuthServerURL() string {
	if s == nil {
		return ""
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.data.AuthServerURL
}

func (s *Session) Scopes() []string {
	if s == nil {
		return nil
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	return append([]string(nil), s.data.Scopes...)
}

func GrantedScopes(scope string) ([]string, error) {
	granted := strings.Fields(scope)
	if atprotooauth.KnownGrant(granted) && !slices.Contains(granted, atprotooauth.ScopeAtproto) {
		return nil, fmt.Errorf("%w (granted %q)", ErrScopeRejected, scope)
	}
	return granted, nil
}

func (s *Session) AccessToken(ctx context.Context) (string, error) {
	if s == nil {
		return "", errors.New("oauthclient: no oauth session")
	}
	s.mu.Lock()
	token := s.data.AccessToken
	s.mu.Unlock()
	if AccessTokenLive(token) {
		return token, nil
	}
	if err := s.refreshStale(ctx); err != nil {
		return "", fmt.Errorf("oauthclient: refreshing the oauth session: %w", err)
	}
	s.mu.Lock()
	token = s.data.AccessToken
	s.mu.Unlock()
	if token == "" {
		return "", errors.New("oauthclient: the oauth session has no access token")
	}
	return token, nil
}

func (s *Session) RefreshAccessToken(ctx context.Context) (string, error) {
	if s == nil {
		return "", errors.New("oauthclient: no oauth session")
	}
	s.mu.Lock()
	rejected := s.data.AccessToken
	s.mu.Unlock()
	if err := s.refreshRejected(ctx, rejected); err != nil {
		return "", err
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.data.AccessToken == "" {
		return "", errors.New("oauthclient: the oauth session has no access token")
	}
	return s.data.AccessToken, nil
}

func (s *Session) SetDPoPNonce(ctx context.Context, host, nonce string) error {
	if s == nil {
		return errors.New("oauthclient: no oauth session")
	}
	if strings.TrimSpace(nonce) == "" {
		return nil
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.data.DPoPHostNonce == nonce {
		return nil
	}
	s.data.DPoPHostNonce = nonce
	return s.persistLocked(ctx)
}

func (s *Session) DPoPProof(ctx context.Context, method, target, accessToken string) (string, error) {
	if s == nil {
		return "", errors.New("oauthclient: no oauth session")
	}
	s.mu.Lock()
	key := s.key
	nonce := s.data.DPoPHostNonce
	s.mu.Unlock()
	return DPoPProof(key, method, target, accessToken, nonce)
}

func (s *Session) HTTPClient(base *http.Client) *http.Client {
	var client http.Client
	if base != nil {
		client = *base
	}
	transport := client.Transport
	if transport == nil {
		transport = http.DefaultTransport
	}
	client.Transport = &httpTransport{session: s, base: transport}
	return &client
}

type httpTransport struct {
	session *Session

	base http.RoundTripper
}

func (t *httpTransport) RoundTrip(req *http.Request) (*http.Response, error) {
	if t == nil || t.session == nil {
		return nil, errors.New("oauthclient: no oauth session")
	}
	base := t.base
	if base == nil {
		base = http.DefaultTransport
	}
	return t.session.doAuthorized(req.Context(), &http.Client{Transport: base}, req)
}

func (s *Session) ServiceAuth(ctx context.Context, audience, lxm string, expires time.Time) (string, error) {
	if s == nil {
		return "", errors.New("oauthclient: no oauth session")
	}
	if audience == "" {
		return "", errors.New("oauthclient: no service auth audience")
	}
	client := &xrpc.Client{Host: s.hostURL, HTTP: s.HTTPClient(s.client), Limit: ReplyLimit}
	token, err := xrpc.GetServiceAuth(ctx, client, audience, lxm, expires)
	if err != nil {
		return "", fmt.Errorf("oauthclient: service auth: %w", err)
	}
	return token, nil
}

func (s *Session) doAuthorized(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
	if client == nil {
		client = &http.Client{Timeout: DefaultHTTPTimeout}
	}
	refreshed := false
	for attempt := range 3 {
		s.mu.Lock()
		accessToken := s.data.AccessToken
		nonce := s.data.DPoPHostNonce
		key := s.key
		s.mu.Unlock()
		proof, err := DPoPProof(key, req.Method, req.URL.String(), accessToken, nonce)
		if err != nil {
			return nil, err
		}
		current := req
		if attempt > 0 {
			current, err = cloneRequest(req)
			if err != nil {
				return nil, err
			}
		}
		current.Header.Set("Authorization", "DPoP "+accessToken)
		current.Header.Set("DPoP", proof)
		resp, err := client.Do(current)
		if err != nil {
			return nil, err
		}
		if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusBadRequest {
			return resp, nil
		}
		header := resp.Header.Get("WWW-Authenticate")
		nextNonce := resp.Header.Get("DPoP-Nonce")
		if nextNonce != "" && (strings.Contains(header, "use_dpop_nonce") || !strings.Contains(header, "invalid_token")) {
			s.mu.Lock()
			s.data.DPoPHostNonce = nextNonce
			err = s.persistLocked(ctx)
			s.mu.Unlock()
			DrainAndClose(resp.Body)
			if err != nil {
				return nil, err
			}
			continue
		}
		if resp.StatusCode == http.StatusUnauthorized && !refreshed {
			refreshed = true
			DrainAndClose(resp.Body)
			if err := s.refreshRejected(ctx, accessToken); err != nil {
				return nil, fmt.Errorf("oauthclient: refreshing the oauth session: %w", err)
			}
			continue
		}
		DrainAndClose(resp.Body)
		return nil, fmt.Errorf("oauthclient: %s %s answered HTTP %d", req.Method, req.URL.Path, resp.StatusCode)
	}
	return nil, errors.New("oauthclient: the oauth session kept asking for a new DPoP nonce")
}

func cloneRequest(req *http.Request) (*http.Request, error) {
	clone := req.Clone(req.Context())
	if req.GetBody != nil {
		body, err := req.GetBody()
		if err != nil {
			return nil, err
		}
		clone.Body = body
	}
	return clone, nil
}

func DrainAndClose(body io.ReadCloser) {
	if body == nil {
		return
	}
	_, _ = io.Copy(io.Discard, io.LimitReader(body, ReplyLimit))
	_ = body.Close()
}

func (s *Session) persist(ctx context.Context) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.persistLocked(ctx)
}

func (s *Session) persistLocked(ctx context.Context) error {
	if s.store == nil {
		return nil
	}
	return s.store.Save(ctx, s.data)
}

func (s *Session) ensureEndpointsLocked(ctx context.Context) error {
	if s.data.AuthServerTokenEndpoint != "" {
		return nil
	}
	client := s.client
	if client == nil {
		client = &http.Client{Timeout: DefaultHTTPTimeout}
	}
	authServer, tokenEndpoint, revokeEndpoint, err := DiscoverAuthServer(ctx, client, s.hostURL)
	if err != nil {
		return err
	}
	s.data.AuthServerURL = authServer
	s.data.AuthServerTokenEndpoint = tokenEndpoint
	if revokeEndpoint != "" {
		s.data.AuthServerRevocationEndpoint = revokeEndpoint
	}
	return nil
}