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

import (
	"context"
	"encoding/base64"
	"errors"
	"fmt"
	"slices"
	"strings"
	"sync/atomic"

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

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

const (
	RefreshErrorInvalidGrant = "invalid_grant"

	RefreshErrorPassThrough = "pass_through"
)

var (
	ErrRefreshPassThrough = errors.New("oauthclient: the token refresher does not refresh this client")

	ErrRefreshWrongSubject = errors.New("oauthclient: the token refresher returned a token set for another account")
)

type TokenRefresher interface {
	RefreshTokens(ctx context.Context, req TokenRefreshRequest) (TokenRefreshResponse, error)
}

type TokenRefresherFunc func(ctx context.Context, req TokenRefreshRequest) (TokenRefreshResponse, error)

func (f TokenRefresherFunc) RefreshTokens(ctx context.Context, req TokenRefreshRequest) (TokenRefreshResponse, error) {
	return f(ctx, req)
}

type DPoPPrivateJWK struct {
	KeyType string `json:"kty"`

	Curve string `json:"crv"`

	X string `json:"x"`

	Y string `json:"y"`

	D string `json:"d"`
}

type TokenRefreshRequest struct {
	SessionID string `json:"session_id"`

	AccountDID string `json:"did"`

	Issuer string `json:"issuer"`

	TokenEndpoint string `json:"token_endpoint"`

	ClientID string `json:"client_id"`

	RefreshToken string `json:"refresh_token"`

	DPoPKey DPoPPrivateJWK `json:"dpop_jwk"`

	DPoPNonce string `json:"dpop_nonce,omitempty"`
}

type TokenRefreshResponse struct {
	AccessToken string `json:"access_token"`

	RefreshToken string `json:"refresh_token"`

	TokenType string `json:"token_type"`

	Scope string `json:"scope"`

	Sub string `json:"sub"`

	ExpiresIn int64 `json:"expires_in,omitempty"`

	ExpiresAt int64 `json:"expires_at,omitempty"`

	DPoPNonce string `json:"dpop_nonce,omitempty"`
}

type TokenRefreshError struct {
	Code string `json:"error"`

	Description string `json:"error_description,omitempty"`

	Status int `json:"status,omitempty"`
}

func (e *TokenRefreshError) Error() string {
	if e.Status != 0 {
		return fmt.Sprintf("oauthclient: the token refresher answered %s (HTTP %d): %s", e.Code, e.Status, e.Description)
	}
	return fmt.Sprintf("oauthclient: the token refresher answered %s: %s", e.Code, e.Description)
}

func (e *TokenRefreshError) Is(target error) bool {
	switch target {
	case ErrSessionExpired:
		return e.Code == RefreshErrorInvalidGrant
	case ErrRefreshPassThrough:
		return e.Code == RefreshErrorPassThrough
	}
	return false
}

type refresherSlot struct {
	refresher TokenRefresher
}

var defaultRefresher atomic.Pointer[refresherSlot]

func (s *Session) tokenRefresher() TokenRefresher {
	if s.refresher != nil {
		return s.refresher
	}
	if slot := defaultRefresher.Load(); slot != nil {
		return slot.refresher
	}
	return nil
}

func (s *Session) refreshVia(base context.Context, refresher TokenRefresher, data SessionData) (SessionData, error) {
	key, err := parseDPoPKey(data.DPoPPrivateKeyMultibase)
	if err != nil {
		return SessionData{}, err
	}
	jwk, err := dpopPrivateJWK(key)
	if err != nil {
		return SessionData{}, err
	}
	ctx, cancel := context.WithTimeout(base, RefreshTimeout)
	defer cancel()
	out, err := refresher.RefreshTokens(ctx, TokenRefreshRequest{
		SessionID:     data.SessionID,
		AccountDID:    s.did,
		Issuer:        data.AuthServerURL,
		TokenEndpoint: data.AuthServerTokenEndpoint,
		ClientID:      data.ClientID,
		RefreshToken:  data.RefreshToken,
		DPoPKey:       jwk,
		DPoPNonce:     data.DPoPAuthServerNonce,
	})
	if err != nil {
		return SessionData{}, err
	}
	return s.acceptRefreshed(data, out)
}

func (s *Session) acceptRefreshed(data SessionData, out TokenRefreshResponse) (SessionData, error) {
	if out.AccessToken == "" || out.RefreshToken == "" {
		return SessionData{}, errors.New("oauthclient: the token refresher returned no access or refresh token")
	}
	if !strings.EqualFold(out.TokenType, "DPoP") {
		return SessionData{}, fmt.Errorf("oauthclient: the token refresher returned token_type %q, not DPoP", out.TokenType)
	}
	if out.Sub != s.did {
		return SessionData{}, fmt.Errorf("%w: sub %q, session %s", ErrRefreshWrongSubject, out.Sub, s.did)
	}
	granted := strings.Fields(out.Scope)
	if !slices.Contains(granted, atprotooauth.ScopeAtproto) {
		return SessionData{}, fmt.Errorf("%w: the token refresher: %w (granted %q)", ErrSessionExpired, ErrScopeRejected, out.Scope)
	}
	data.Scopes = granted
	data.AccessToken = out.AccessToken
	data.RefreshToken = out.RefreshToken
	if out.DPoPNonce != "" {
		data.DPoPAuthServerNonce = out.DPoPNonce
	}
	return data, nil
}

func dpopPrivateJWK(key atcrypto.PrivateKeyExportable) (DPoPPrivateJWK, error) {
	p256, ok := key.(*atcrypto.PrivateKeyP256)
	if !ok {
		return DPoPPrivateJWK{}, fmt.Errorf("oauthclient: the oauth session DPoP key type %T is not P-256", key)
	}
	public, err := p256.PublicKey()
	if err != nil {
		return DPoPPrivateJWK{}, err
	}
	jwk, err := public.JWK()
	if err != nil {
		return DPoPPrivateJWK{}, err
	}
	return DPoPPrivateJWK{
		KeyType: jwk.KeyType,
		Curve:   jwk.Curve,
		X:       jwk.X,
		Y:       jwk.Y,
		D:       base64.RawURLEncoding.EncodeToString(p256.Bytes()),
	}, nil
}