~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
159 lines · 3.8 KB Raw History
package participant

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"strings"
	"time"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/market"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/xrpc"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/contract/pds"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/ttlcache"
)

const (
	ServiceAuthLifetime = 5 * time.Minute

	ServiceAuthRefreshMargin = 30 * time.Second
)

type PDSServiceAuth struct {
	Open func(ctx context.Context) (pds.ServiceAuth, error)

	DID string

	Lifetime time.Duration
}

var _ xrpc.Minter = PDSServiceAuth{}

func (a PDSServiceAuth) ServiceAuth(ctx context.Context, audience, lxm string) (string, error) {
	if a.Open == nil || a.DID == "" {
		return "", errors.New("participant: the PDS service-auth handle names no account")
	}
	minter, err := a.Open(ctx)
	if err != nil {
		return "", err
	}
	lifetime := a.Lifetime
	if lifetime <= 0 {
		lifetime = ServiceAuthLifetime
	}
	return minter.ServiceAuth(ctx, a.DID, audience, lxm, time.Now().Add(lifetime))
}

type SessionServiceAuth struct {
	Endpoint string

	HTTP *http.Client

	Auth xrpc.Auth

	Lifetime time.Duration
}

var _ xrpc.Minter = SessionServiceAuth{}

func (m SessionServiceAuth) ServiceAuth(ctx context.Context, audience, lxm string) (string, error) {
	if strings.TrimSpace(m.Endpoint) == "" {
		return "", errors.New("participant: the session service-auth has no PDS URL")
	}
	lifetime := m.Lifetime
	if lifetime <= 0 {
		lifetime = ServiceAuthLifetime
	}
	client := &xrpc.Client{Host: m.Endpoint, HTTP: m.HTTP, Auth: m.Auth}
	return xrpc.GetServiceAuth(ctx, client, audience, lxm, time.Now().Add(lifetime))
}

type Tokens struct {
	minter xrpc.Minter

	timeout time.Duration

	cache *ttlcache.Cache[string, string]
}

func NewTokens(minter xrpc.Minter, timeout time.Duration) *Tokens {
	return &Tokens{minter: minter, timeout: timeout, cache: ttlcache.New[string, string](ttlcache.Options{})}
}

func (t *Tokens) Token(ctx context.Context, host, lxm string) (string, error) {
	if t == nil || t.minter == nil {
		return "", errors.New("participant: no service-auth minter is configured")
	}
	aud := market.ServiceDIDForHost(host)
	return t.cache.GetOrLoad(ctx, aud+"\x00"+lxm, func(ctx context.Context) (string, time.Duration, error) {
		requested := time.Now().Add(ServiceAuthLifetime)
		callCtx := ctx
		if t.timeout > 0 {
			var cancel context.CancelFunc
			callCtx, cancel = context.WithTimeout(ctx, t.timeout)
			defer cancel()
		}
		token, err := t.minter.ServiceAuth(callCtx, aud, lxm)
		if err != nil {
			return "", 0, fmt.Errorf("getServiceAuth %s: %w", aud, err)
		}
		ttl := time.Until(CacheExpiry(token, requested))
		if ttl <= 0 {
			ttl = -1
		}
		return token, ttl, nil
	})
}

func (t *Tokens) Invalidate(host, lxm string) {
	if t == nil {
		return
	}
	t.cache.Delete(market.ServiceDIDForHost(host) + "\x00" + lxm)
}

func (t *Tokens) Source(host, lxm string) xrpc.Source[string] {
	return tokenSource{tokens: t, host: host, lxm: lxm}
}

type tokenSource struct {
	tokens *Tokens

	host string

	lxm string
}

func (s tokenSource) Get(ctx context.Context) (string, error) {
	return s.tokens.Token(ctx, s.host, s.lxm)
}

func (s tokenSource) Invalidate() {
	s.tokens.Invalidate(s.host, s.lxm)
}

func CacheExpiry(token string, requested time.Time) time.Time {
	fallback := requested.Add(-ServiceAuthRefreshMargin)
	parts := strings.Split(token, ".")
	if len(parts) < 2 {
		return fallback
	}
	payload, err := base64.RawURLEncoding.DecodeString(parts[1])
	if err != nil {
		return fallback
	}
	var claims struct {
		Exp int64 `json:"exp"`
	}
	if json.Unmarshal(payload, &claims) != nil || claims.Exp == 0 {
		return fallback
	}
	expires := time.Unix(claims.Exp, 0).Add(-ServiceAuthRefreshMargin)
	if expires.After(requested) {
		return fallback
	}
	return expires
}