~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
942 lines · 32.1 KB Raw History
package atproto

import (
	"cmp"
	"context"
	"errors"
	"fmt"
	"net/http"
	"net/url"
	"slices"
	"strings"
	"time"

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

	"github.com/johnandersen777/socialweb-computer-kcp/api/v1alpha1"
	atprotooauth "github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/oauth"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/oauthclient"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/conds"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/opt"

	commonv1 "github.com/johnandersen777/socialweb-computer-kcp/api/common/v1alpha1"
)

const (
	DefaultOAuthScope = "atproto"

	QRPollInterval = 3 * time.Second

	browserPendingTTL = 15 * time.Minute
)

type OAuthOptions struct {
	ClientID string

	RedirectURI string

	Scopes []string

	QRScopes []string

	QROrigin string

	Directory identity.Directory

	HTTPClient *http.Client

	States oauthclient.StateStore

	StateRef *commonv1.SecretRef

	CLIKey atcrypto.PrivateKey

	CLIDID string

	QRSigner oauthclient.ServiceAuthSigner
}

type qrTransfer struct {
	transfer *oauthclient.QRTransfer

	url string

	issued time.Time

	expires time.Time
}

var errNoSessionRef = errors.New("marketidentity: the identity has no sessionRef")

func (a *Accounts) oauthHTTP() *http.Client {
	if a.oauth.HTTPClient != nil {
		return a.oauth.HTTPClient
	}
	return a.http
}

func (a *Accounts) oauthScopes() []string {
	if len(a.oauth.Scopes) > 0 {
		return append([]string(nil), a.oauth.Scopes...)
	}
	return []string{DefaultOAuthScope}
}

func (a *Accounts) qrScopes() []string {
	if len(a.oauth.QRScopes) > 0 {
		return append([]string(nil), a.oauth.QRScopes...)
	}
	return a.oauthScopes()
}

func (a *Accounts) scopesFor(mode v1alpha1.MarketIdentityMode) []string {
	if mode == v1alpha1.MarketIdentityModeOAuthQr {
		return a.qrScopes()
	}
	return a.oauthScopes()
}

func ownScopes(id *v1alpha1.MarketIdentity) []string {
	if id == nil || id.Spec.Mode == v1alpha1.MarketIdentityModeOAuthSessionProxy {
		return nil
	}
	return strings.Fields(id.Spec.SessionScope)
}

func (a *Accounts) requestedScopes(id *v1alpha1.MarketIdentity) []string {
	if scopes := ownScopes(id); len(scopes) > 0 {
		return scopes
	}
	return a.scopesFor(id.Spec.Mode)
}

func (a *Accounts) statesFor(workspace string) oauthclient.StateStore {
	if a.oauth.States == nil && a.oauth.StateRef != nil {
		if vault := a.vaultFor(workspace); vault != nil {
			return &VaultStateStore{Vault: vault, Ref: *a.oauth.StateRef}
		}
	}
	return a.states
}

func (a *Accounts) oauthBrowser(ctx context.Context, workspace string, id *v1alpha1.MarketIdentity, generation int64,
	status v1alpha1.MarketIdentityStatus) (Result, error) {
	key := qrKey(workspace, id.Name)
	session, handle, ref, err := a.restoreOAuthSession(ctx, workspace, id, &status)
	if err == nil {
		signed := a.sessionSignedIn(ctx, id, status, generation, session, handle, ref)
		if signed.Status.PendingAction == nil {
			a.browser.Delete(key)
			return signed, nil
		}
		return a.upgradeURL(ctx, workspace, id, generation, signed), nil
	}
	if errors.Is(err, errNoSessionRef) || ref == nil {
		return failed(status, generation, "MissingSessionRef", "mode oauthBrowser needs spec.sessionRef for the oauth session; no secret is written to status"), nil
	}
	need, ok := signInNeeded(&status, err)
	if !ok {
		return pending(status, generation, sessionErrorReason(err), "restoring the oauth session: "+err.Error()), err
	}
	kind := v1alpha1.PendingActionVisitUrl
	now := time.Now()
	status.SessionRef = ref
	issue, issued := a.browser.Get(key)
	if issued && issue.failure != "" && !strings.Contains(need.message, issue.failure) {
		need.message = cmp.Or(need.message, pendingMessage(v1alpha1.MarketIdentityPhaseAwaitingAuthorization)) + "; the last sign-in did not complete: " + issue.failure
	}
	if need.again {
		kind = v1alpha1.PendingActionSignInAgain
		signInAgainStatus(&status, generation, need.reason, need.message)
	} else {
		status.Phase = v1alpha1.MarketIdentityPhaseAwaitingAuthorization
		status.ObservedGeneration = generation
		status.Message = cmp.Or(need.message, pendingMessage(status.Phase))
		reason := cmp.Or(need.reason, string(status.Phase))
		conds.Set(&status.Conditions, generation, v1alpha1.ConditionSignedIn, false, reason, status.Message)
		setReady(&status, generation, false, reason, status.Message)
	}
	if expires, ok := pendingExpiry(id.Status.PendingAction, kind, now); ok && a.browserIssueLive(ctx, workspace, id, generation, issue, issued) {
		return Result{Status: status, RequeueAfter: untilOrZero(expires, now)}, nil
	}
	if issued && !issue.spent {
		a.spendBrowserIssue(ctx, workspace, id, issue.state, nil)
	}
	visit, state, expires, err := a.beginBrowserAuthorization(ctx, workspace, id)
	if err != nil {
		return failed(status, generation, "OAuthBeginFailed", err.Error()), err
	}
	a.browser.SetTTL(key, browserIssue{state: state, url: visit, generation: generation, account: accountKey(id), failure: issue.failure, issued: issuedAt(id, now)}, browserPendingTTL)
	status.PendingAction = &v1alpha1.MarketIdentityPendingAction{Kind: kind, URL: visit, Handle: strings.TrimSpace(id.Spec.Handle),
		ExpiresAt: expires.UTC().Format(time.RFC3339)}
	return Result{Status: status, RequeueAfter: time.Until(expires)}, nil
}

func (a *Accounts) upgradeURL(ctx context.Context, workspace string, id *v1alpha1.MarketIdentity, generation int64, signed Result) Result {
	key := qrKey(workspace, id.Name)
	now := time.Now()
	action := signed.Status.PendingAction
	issue, issued := a.browser.Get(key)
	if expires, ok := pendingExpiry(id.Status.PendingAction, v1alpha1.PendingActionSignInAgain, now); ok &&
		a.browserIssueLive(ctx, workspace, id, generation, issue, issued) {
		action.URL, action.ExpiresAt = id.Status.PendingAction.URL, id.Status.PendingAction.ExpiresAt
		signed.RequeueAfter = minPositive(signed.RequeueAfter, untilOrZero(expires, now))
		return signed
	}
	if issued && !issue.spent {
		a.spendBrowserIssue(ctx, workspace, id, issue.state, nil)
	}
	visit, state, expires, err := a.beginBrowserAuthorization(ctx, workspace, id)
	if err != nil {
		a.log.Warn("the scope upgrade sign-in url is not issued", "workspace", workspace, "identity", id.Name, "err", err.Error())
		return signed
	}
	a.browser.SetTTL(key, browserIssue{state: state, url: visit, generation: generation, account: accountKey(id), issued: issuedAt(id, now)}, browserPendingTTL)
	action.URL, action.ExpiresAt = visit, expires.UTC().Format(time.RFC3339)
	signed.RequeueAfter = minPositive(signed.RequeueAfter, time.Until(expires))
	return signed
}

func minPositive(a, b time.Duration) time.Duration {
	switch {
	case a <= 0:
		return b
	case b <= 0:
		return a
	}
	return min(a, b)
}

type browserIssue struct {
	state string

	url string

	generation int64

	account string

	failure string

	spent bool

	issued time.Time
}

func (a *Accounts) browserIssueLive(ctx context.Context, workspace string, id *v1alpha1.MarketIdentity, generation int64, issue browserIssue, issued bool) bool {
	action := id.Status.PendingAction
	if !issued || issue.spent || action == nil || issue.url != action.URL || issue.generation != generation || issue.account != accountKey(id) ||
		issue.issued.Before(signInRequestedAt(id)) {
		return false
	}
	live, err := a.statesFor(workspace).Has(ctx, issue.state)
	if err != nil {
		a.log.Warn("the oauth callback state is not readable; the visit url stays", "workspace", workspace, "identity", id.Name, "err", err.Error())
		return true
	}
	return live
}

func (a *Accounts) spendBrowserIssue(ctx context.Context, workspace string, id *v1alpha1.MarketIdentity, state string, failure error) {
	key := qrKey(workspace, id.Name)
	issue, ok := a.browser.Get(key)
	if !ok || (state != "" && state != issue.state) {
		return
	}
	if _, _, err := a.statesFor(workspace).Take(ctx, issue.state); err != nil {
		a.log.Warn("the spent oauth callback state is not removed", "workspace", workspace, "identity", id.Name, "err", err.Error())
	}
	issue.spent = true
	if failure != nil {
		issue.failure = strings.TrimPrefix(strings.TrimPrefix(failure.Error(), "marketidentity: "), "oauthclient: ")
	}
	a.browser.SetTTL(key, issue, browserPendingTTL)
}

type signInNeed struct {
	again bool

	reason string

	message string
}

func signInNeeded(status *v1alpha1.MarketIdentityStatus, err error) (signInNeed, bool) {
	var mismatch *accountMismatchError
	if errors.As(err, &mismatch) {
		status.Did, status.Handle, status.PdsURL = "", "", ""
		return signInNeed{reason: "AccountMismatch", message: mismatch.text}, true
	}
	var unverified *oauthclient.QRUnverifiedError
	if errors.As(err, &unverified) {
		return signInNeed{reason: "QRSessionUnverified", message: qrUnverifiedMessage(unverified)}, true
	}
	if reason, message, again := signInAgainReason(err); again {
		return signInNeed{again: true, reason: reason, message: message}, true
	}
	if !errors.Is(err, oauthclient.ErrNoSession) {
		return signInNeed{}, false
	}
	if signedOut(status) {
		return signInNeed{reason: v1alpha1.ReasonSignedOut, message: signedOutMessage}, true
	}
	return signInNeed{}, true
}

func qrUnverifiedMessage(e *oauthclient.QRUnverifiedError) string {
	if e.VerifiedDID == "" {
		return fmt.Sprintf("refused the QR session transfer: the QR origin did not verify the session of %s at the account PDS (no verifiedDid); scan the new QR code to sign in again", e.UserDID)
	}
	return fmt.Sprintf("refused the QR session transfer: the QR origin verified %s at the account PDS, but the session names %s; scan the new QR code to sign in again", e.VerifiedDID, e.UserDID)
}

func sessionErrorReason(err error) string {
	var unverified *accountUnverifiedError
	if errors.As(err, &unverified) {
		return "AccountUnverified"
	}
	return "SessionError"
}

func (a *Accounts) oauthQr(ctx context.Context, workspace string, id *v1alpha1.MarketIdentity, generation int64,
	status v1alpha1.MarketIdentityStatus) (Result, error) {
	key := qrKey(workspace, id.Name)
	session, handle, ref, err := a.restoreOAuthSession(ctx, workspace, id, &status)
	if err == nil {
		a.qr.Delete(key)
		return a.sessionSignedIn(ctx, id, status, generation, session, handle, ref), nil
	}
	if errors.Is(err, errNoSessionRef) || ref == nil {
		return failed(status, generation, "MissingSessionRef", "mode oauthQr needs spec.sessionRef for the oauth session; no secret is written to status"), nil
	}
	need, ok := signInNeeded(&status, err)
	if !ok {
		return pending(status, generation, sessionErrorReason(err), "restoring the oauth session: "+err.Error()), err
	}
	status.SessionRef = ref
	qr, ok := a.qr.Get(key)
	if !ok || !time.Now().Before(qr.expires) || qr.issued.Before(signInRequestedAt(id)) {
		qr, err = a.newQrState(key, id)
		if err != nil {
			return failed(status, generation, "OAuthBeginFailed", err.Error()), nil
		}
		return Result{Status: qrPending(status, generation, qr, need), RequeueAfter: QRPollInterval}, nil
	}
	sessionQr, done, err := qr.transfer.PollOnce(ctx)
	if err != nil {
		if errors.Is(err, oauthclient.ErrQRConsumed) || errors.Is(err, oauthclient.ErrQRTimeout) {
			a.qr.Delete(key)
		}
		return pending(status, generation, "QRPollFailed", err.Error()), err
	}
	if !done {
		return Result{Status: qrPending(status, generation, qr, need), RequeueAfter: QRPollInterval}, nil
	}
	a.qr.Delete(key)
	err = sessionQr.Verified()
	if err != nil {
		a.log.Info("qr_session_unverified", "workspace", workspace, "identity", id.Name, "did", sessionQr.UserDID,
			"verifiedDid", sessionQr.VerifiedDID)
	} else {
		if !sessionQr.GrantReported() {
			a.log.Info("qr_session_grant_unverified", "workspace", workspace, "identity", id.Name, "did", sessionQr.UserDID,
				"assumed", strings.Join(a.requestedScopes(id), " "))
		}
		var data oauthclient.SessionData
		data, err = sessionQr.SessionData(a.sessionClientID(id), a.requestedScopes(id))
		if err == nil {
			data = a.sessionWithEndpoints(ctx, data)
			session, handle, err = a.openSessionData(ctx, workspace, id, data, ref, sessionQr.Handle)
		} else if !errors.Is(err, oauthclient.ErrScopeRejected) {
			return failed(status, generation, "QRSessionInvalid", err.Error()), err
		}
	}
	if need, ok := signInNeeded(&status, err); ok && err != nil {
		if qr, err = a.newQrState(key, id); err != nil {
			return failed(status, generation, "OAuthBeginFailed", err.Error()), nil
		}
		return Result{Status: qrPending(status, generation, qr, need), RequeueAfter: QRPollInterval}, nil
	}
	if err != nil {
		return pending(status, generation, "SessionRestoreFailed", err.Error()), err
	}
	return a.sessionSignedIn(ctx, id, status, generation, session, handle, ref), nil
}

func qrPending(status v1alpha1.MarketIdentityStatus, generation int64, qr *qrTransfer, need signInNeed) v1alpha1.MarketIdentityStatus {
	status.PendingAction = &v1alpha1.MarketIdentityPendingAction{Kind: v1alpha1.PendingActionScanQr, URL: qr.url,
		ExpiresAt: qr.expires.UTC().Format(time.RFC3339)}
	if need.again {
		status.PendingAction.Kind = v1alpha1.PendingActionSignInAgain
		signInAgainStatus(&status, generation, need.reason, need.message)
		return status
	}
	status.Phase = v1alpha1.MarketIdentityPhaseAwaitingScan
	status.ObservedGeneration = generation
	status.Message = cmp.Or(need.message, pendingMessage(status.Phase))
	reason := cmp.Or(need.reason, "AwaitingScan")
	if need.reason != "" {
		conds.Set(&status.Conditions, generation, v1alpha1.ConditionSignedIn, false, reason, status.Message)
	}
	setReady(&status, generation, false, reason, status.Message)
	return status
}

func (a *Accounts) oauthDelegated(ctx context.Context, workspace string, id *v1alpha1.MarketIdentity, generation int64,
	status v1alpha1.MarketIdentityStatus) (Result, error) {
	session, handle, ref, err := a.restoreOAuthSession(ctx, workspace, id, &status)
	if err == nil {
		return a.sessionSignedIn(ctx, id, status, generation, session, handle, ref), nil
	}
	if errors.Is(err, errNoSessionRef) {
		return pending(status, generation, "AwaitingSession", "mode oauthDelegated waits for spec.sessionRef"), nil
	}
	if need, ok := signInNeeded(&status, err); ok && need.reason != "" {
		status.SessionRef = ref
		status.PendingAction = &v1alpha1.MarketIdentityPendingAction{Kind: v1alpha1.PendingActionSignInAgain, Handle: id.Spec.Handle}
		signInAgainStatus(&status, generation, need.reason, need.message)
		return Result{Status: status}, nil
	}
	return pending(status, generation, sessionErrorReason(err), "restoring the oauth session: "+err.Error()), err
}

func (a *Accounts) pinnedClientID(id *v1alpha1.MarketIdentity) string {
	if id != nil && id.Spec.Mode == v1alpha1.MarketIdentityModeOAuthQr {
		return oauthclient.QRClientID(a.oauth.QROrigin)
	}
	return ""
}

func (a *Accounts) sessionClientID(id *v1alpha1.MarketIdentity) string {
	if id != nil && id.Spec.Mode == v1alpha1.MarketIdentityModeOAuthQr {
		if clientID := oauthclient.QRClientID(a.oauth.QROrigin); clientID != "" {
			return clientID
		}
	}
	return a.oauthClientID(id)
}

func (a *Accounts) oauthClientID(id *v1alpha1.MarketIdentity) string {
	if id != nil && strings.TrimSpace(id.Spec.OAuthClientID) != "" {
		return strings.TrimSpace(id.Spec.OAuthClientID)
	}
	return strings.TrimSpace(a.oauth.ClientID)
}

func (a *Accounts) oauthRedirectURI(id *v1alpha1.MarketIdentity) string {
	if id != nil && strings.TrimSpace(id.Spec.OAuthRedirectURI) != "" {
		return strings.TrimSpace(id.Spec.OAuthRedirectURI)
	}
	return strings.TrimSpace(a.oauth.RedirectURI)
}

func (a *Accounts) beginBrowserAuthorization(ctx context.Context, workspace string, id *v1alpha1.MarketIdentity) (string, string, time.Time, error) {
	clientID := a.oauthClientID(id)
	redirectURI := a.oauthRedirectURI(id)
	if clientID == "" {
		return "", "", time.Time{}, errors.New("marketidentity: mode oauthBrowser needs an oauth clientID")
	}
	if redirectURI == "" {
		return "", "", time.Time{}, errors.New("marketidentity: mode oauthBrowser needs an oauth redirectURI")
	}
	pdsURL, err := a.oauthPDSURL(ctx, id)
	if err != nil {
		return "", "", time.Time{}, err
	}
	client, err := a.sessionHTTP(ctx, workspace, id)
	if err != nil {
		return "", "", time.Time{}, err
	}
	as, err := oauthclient.DiscoverAuthorization(ctx, client, pdsURL)
	if err != nil {
		return "", "", time.Time{}, err
	}
	if as.AuthorizationEndpoint == "" {
		return "", "", time.Time{}, errors.New("marketidentity: the auth server metadata names no authorization endpoint")
	}
	state, err := oauthclient.NewCallbackState()
	if err != nil {
		return "", "", time.Time{}, err
	}
	verifier, challenge, err := oauthclient.GeneratePKCE()
	if err != nil {
		return "", "", time.Time{}, err
	}
	key, err := atcrypto.GeneratePrivateKeyP256()
	if err != nil {
		return "", "", time.Time{}, err
	}
	scopes := a.oauthScopes()
	if own := ownScopes(id); len(own) > 0 {
		scopes = own
	}
	pendingAuth := oauthclient.PendingAuth{
		State:                   state,
		CodeVerifier:            verifier,
		RedirectURI:             redirectURI,
		ClientID:                clientID,
		HostURL:                 pdsURL,
		Issuer:                  as.Issuer,
		AuthServerURL:           as.URL,
		TokenEndpoint:           as.TokenEndpoint,
		Scopes:                  scopes,
		DPoPPrivateKeyMultibase: key.Multibase(),
		Entryway:                as.Entryway,
	}
	if err := a.statesFor(workspace).Put(ctx, pendingAuth); err != nil {
		return "", "", time.Time{}, err
	}
	query := url.Values{
		"response_type":         {"code"},
		"client_id":             {clientID},
		"redirect_uri":          {redirectURI},
		"scope":                 {strings.Join(scopes, " ")},
		"state":                 {state},
		"code_challenge":        {challenge},
		"code_challenge_method": {"S256"},
	}
	if hint := cmp.Or(strings.TrimSpace(id.Spec.Did), strings.TrimSpace(id.Spec.Handle)); hint != "" {
		query.Set("login_hint", hint)
	}
	parsed, err := url.Parse(as.AuthorizationEndpoint)
	if err != nil {
		return "", "", time.Time{}, err
	}
	parsed.RawQuery = query.Encode()
	return parsed.String(), state, time.Now().Add(browserPendingTTL), nil
}

func (a *Accounts) CompleteBrowserAuthorization(ctx context.Context, workspace string, id *v1alpha1.MarketIdentity, r *http.Request) error {
	if id.Spec.Mode != v1alpha1.MarketIdentityModeOAuthBrowser {
		return fmt.Errorf("marketidentity: MarketIdentity %s is mode %s, not oauthBrowser", id.Name, id.Spec.Mode)
	}
	err := a.completeBrowserAuthorization(ctx, workspace, id, r)
	if err != nil {
		a.log.Info("the oauth callback failed; the visit url is reissued", "workspace", workspace, "identity", id.Name, "err", err.Error())
		state := ""
		if r != nil && r.URL != nil {
			state = r.URL.Query().Get("state")
		}
		a.spendBrowserIssue(ctx, workspace, id, state, err)
	}
	return err
}

func (a *Accounts) completeBrowserAuthorization(ctx context.Context, workspace string, id *v1alpha1.MarketIdentity, r *http.Request) error {
	ref := oauthSessionRef(id, &id.Status)
	if ref == nil {
		return errNoSessionRef
	}
	client, err := a.sessionHTTP(ctx, workspace, id)
	if err != nil {
		return err
	}
	data, err := oauthclient.ExchangeCallback(ctx, a.statesFor(workspace), r, client, a.oauth.Directory)
	if err != nil {
		return err
	}
	_, _, err = a.openSessionData(ctx, workspace, id, a.sessionWithEndpoints(ctx, data), ref, "")
	return err
}

func (a *Accounts) oauthPDSURL(ctx context.Context, id *v1alpha1.MarketIdentity) (string, error) {
	if raw := strings.TrimSpace(id.Spec.PdsURL); raw != "" {
		return raw, nil
	}
	account := cmp.Or(strings.TrimSpace(id.Spec.Did), strings.TrimSpace(id.Spec.Handle))
	if account == "" || a.oauth.Directory == nil {
		return "", errors.New("marketidentity: the oauth mode names neither pdsUrl nor a resolvable did or handle")
	}
	atid, err := syntax.ParseAtIdentifier(account)
	if err != nil {
		return "", fmt.Errorf("marketidentity: the oauth account %q: %w", account, err)
	}
	ident, err := a.oauth.Directory.Lookup(ctx, atid)
	if err != nil {
		return "", fmt.Errorf("marketidentity: resolving the oauth account %q: %w", account, err)
	}
	pds := strings.TrimSpace(ident.PDSEndpoint())
	if pds == "" {
		return "", fmt.Errorf("marketidentity: the identity %s names no PDS", ident.DID.String())
	}
	return pds, nil
}

func (a *Accounts) restoreOAuthSession(ctx context.Context, workspace string, id *v1alpha1.MarketIdentity,
	status *v1alpha1.MarketIdentityStatus) (*oauthclient.Session, string, *commonv1.SecretRef, error) {
	ref := oauthSessionRef(id, status)
	if ref == nil {
		return nil, "", nil, errNoSessionRef
	}
	held := a.held.get(workspace, id)
	held.open.Lock()
	defer held.open.Unlock()
	if session := held.auth.Session(); session != nil && held.ref == *ref {
		if account := accountKey(id); held.account != account {
			handle, err := a.checkAccount(ctx, id, session.DID(), session.HostURL(), session.AuthServerURL(), "")
			if err != nil {
				held.auth.Drop()
				held.account = ""
				return nil, "", ref, err
			}
			held.account, held.handle = account, handle
		}
		if _, err := session.AccessToken(ctx); err != nil {
			if oauthExpired(err) {
				held.auth.Drop()
			}
			return nil, "", ref, err
		}
		if err := a.scopeCheck(workspace, id, held, session.Scopes()); err != nil {
			held.auth.Drop()
			return nil, "", ref, err
		}
		return session, held.handle, ref, nil
	}
	vault := a.vaultFor(workspace)
	if vault == nil {
		return nil, "", ref, errors.New("marketidentity: no vault reads the oauth session")
	}
	client, err := a.sessionHTTP(ctx, workspace, id)
	if err != nil {
		return nil, "", ref, err
	}
	store := &vaultSessionStore{vault: vault, ref: *ref, clientID: a.pinnedClientID(id)}
	stored, err := store.List(ctx)
	if err != nil {
		return nil, "", ref, err
	}
	if len(stored) == 0 {
		return nil, "", ref, oauthclient.ErrNoSession
	}
	handle, err := a.checkAccount(ctx, id, stored[0].AccountDID, stored[0].HostURL, stored[0].AuthServerURL, "")
	if err != nil {
		return nil, "", ref, err
	}
	session, err := oauthclient.Open(ctx, oauthclient.Options{
		DID:        stored[0].AccountDID,
		Handle:     handle,
		RepoDID:    stored[0].AccountDID,
		ClientID:   a.sessionClientID(id),
		Scopes:     a.requestedScopes(id),
		Directory:  a.oauth.Directory,
		HTTPClient: client,
		Store:      store,
		Log:        a.log,
	})
	if errors.Is(err, oauthclient.ErrInvalidSession) {
		_ = vault.Delete(ctx, *ref)
		return nil, "", ref, oauthclient.ErrNoSession
	}
	if err != nil {
		return nil, "", ref, err
	}
	if err := a.scopeCheck(workspace, id, held, session.Scopes()); err != nil {
		return nil, "", ref, err
	}
	held.ref = *ref
	held.account, held.handle = accountKey(id), handle
	held.auth.Hold(session)
	return session, handle, ref, nil
}

type scopeChangedError struct {
	missing []string
}

func (e *scopeChangedError) Error() string {
	return "marketidentity: " + e.message()
}

func (e *scopeChangedError) message() string {
	return "the oauth session lacks scopes the identity now needs (" + strings.Join(e.subjects(), ", ") + "); sign in again"
}

func (e *scopeChangedError) subjects() []string {
	subjects := make([]string, 0, len(e.missing))
	for _, scope := range e.missing {
		subjects = append(subjects, atprotooauth.ScopeSubject(scope))
	}
	return subjects
}

func (a *Accounts) requiredScopes(id *v1alpha1.MarketIdentity) []string {
	if id.Spec.Mode == v1alpha1.MarketIdentityModeOAuthSessionProxy {
		return strings.Fields(id.Spec.SessionScope)
	}
	if scopes := ownScopes(id); len(scopes) > 0 {
		return scopes
	}
	return atprotooauth.RequiredScopes(a.oauthScopes())
}

func (a *Accounts) scopeCheck(workspace string, id *v1alpha1.MarketIdentity, held *heldAccount, granted []string) error {
	if !atprotooauth.KnownGrant(granted) {
		return nil
	}
	missing := atprotooauth.MissingScopes(a.requiredScopes(id), granted)
	if lacking := strings.Join(missing, " "); lacking != held.lacking {
		held.lacking = lacking
		if lacking != "" {
			a.log.Info("the oauth session lacks scopes the identity now needs", "workspace", workspace, "identity", id.Name, "missing", missing)
		}
	}
	if len(missing) == 0 {
		return nil
	}
	return &scopeChangedError{missing: missing}
}

func signInAgainReason(err error) (string, string, bool) {
	var changed *scopeChangedError
	switch {
	case errors.As(err, &changed):
		return "ScopeChanged", changed.message(), true
	case errors.Is(err, oauthclient.ErrScopeRejected):
		return "ScopeRejected", "the authorization server granted no atproto scope; sign in again", true
	case oauthExpired(err):
		return string(v1alpha1.MarketIdentityPhaseSessionExpired), pendingMessage(v1alpha1.MarketIdentityPhaseSessionExpired), true
	}
	return "", "", false
}

func signInAgainStatus(status *v1alpha1.MarketIdentityStatus, generation int64, reason, message string) {
	status.Phase = v1alpha1.MarketIdentityPhaseSessionExpired
	status.ObservedGeneration = generation
	status.Message = message
	conds.Set(&status.Conditions, generation, v1alpha1.ConditionSignedIn, false, reason, message)
	setReady(status, generation, false, reason, message)
}

func (a *Accounts) sessionWithEndpoints(ctx context.Context, data oauthclient.SessionData) oauthclient.SessionData {
	if data.AuthServerURL != "" && data.AuthServerTokenEndpoint != "" {
		return data
	}
	authServer, tokenEndpoint, revokeEndpoint, err := oauthclient.DiscoverAuthServer(ctx, a.oauthHTTP(), data.HostURL)
	if err != nil {
		a.log.Warn("oauth_session_endpoints_not_discovered", "did", data.AccountDID, "err", err.Error())
		return data
	}
	data.AuthServerURL = authServer
	data.AuthServerTokenEndpoint = tokenEndpoint
	if revokeEndpoint != "" {
		data.AuthServerRevocationEndpoint = revokeEndpoint
	}
	return data
}

func (a *Accounts) openSessionData(ctx context.Context, workspace string, id *v1alpha1.MarketIdentity, data oauthclient.SessionData,
	ref *commonv1.SecretRef, claimed string) (*oauthclient.Session, string, error) {
	vault := a.vaultFor(workspace)
	if vault == nil {
		return nil, "", errors.New("marketidentity: no vault writes the oauth session")
	}
	client, err := a.sessionHTTP(ctx, workspace, id)
	if err != nil {
		return nil, "", err
	}
	handle, err := a.checkAccount(ctx, id, data.AccountDID, data.HostURL, data.AuthServerURL, claimed)
	if err != nil {
		return nil, "", err
	}
	held := a.held.get(workspace, id)
	held.open.Lock()
	defer held.open.Unlock()
	held.auth.Drop()
	held.account = ""
	if err := saveSession(ctx, vault, ref, data); err != nil {
		return nil, "", err
	}
	session, err := oauthclient.New(data, oauthclient.Options{
		DID:        data.AccountDID,
		Handle:     handle,
		RepoDID:    data.AccountDID,
		PDSURL:     data.HostURL,
		ClientID:   a.sessionClientID(id),
		Scopes:     a.requestedScopes(id),
		Directory:  a.oauth.Directory,
		HTTPClient: client,
		Store:      &vaultSessionStore{vault: vault, ref: *ref, clientID: a.pinnedClientID(id)},
		Log:        a.log,
	})
	if err != nil {
		return nil, "", err
	}
	_ = session.EnsureEndpoints(ctx)
	if _, err := session.AccessToken(ctx); err != nil {
		return nil, "", err
	}
	if err := a.scopeCheck(workspace, id, held, session.Scopes()); err != nil {
		return nil, "", err
	}
	held.ref = *ref
	held.account, held.handle = accountKey(id), handle
	held.auth.Hold(session)
	return session, handle, nil
}

func saveSession(ctx context.Context, vault SecretStore, ref *commonv1.SecretRef, data oauthclient.SessionData) error {
	if ref == nil {
		return errNoSessionRef
	}
	if vault == nil {
		return errors.New("marketidentity: no vault writes the oauth session")
	}
	return (&vaultSessionStore{vault: vault, ref: *ref}).Save(ctx, data)
}

func (a *Accounts) sessionSignedIn(ctx context.Context, id *v1alpha1.MarketIdentity, status v1alpha1.MarketIdentityStatus, generation int64,
	session *oauthclient.Session, handle string, ref *commonv1.SecretRef) Result {
	token, _ := session.AccessToken(ctx)
	out := signedIn(status, generation, session.DID(), handle, session.HostURL(), ref, token)
	if missing := a.upgradeScopes(id, session.Scopes()); len(missing) > 0 {
		subjects := make([]string, 0, len(missing))
		for _, scope := range missing {
			subjects = append(subjects, atprotooauth.ScopeSubject(scope))
		}
		out.Status.PendingAction = &v1alpha1.MarketIdentityPendingAction{Kind: v1alpha1.PendingActionSignInAgain,
			Reason: v1alpha1.PendingActionReasonScopeUpgrade, Handle: strings.TrimSpace(handle), MissingScopes: missing,
			Message: "the session works; sign in again to grant " + strings.Join(subjects, ", ")}
	}
	return out
}

func (a *Accounts) upgradeScopes(id *v1alpha1.MarketIdentity, granted []string) []string {
	if !atprotooauth.KnownGrant(granted) || len(ownScopes(id)) > 0 {
		return nil
	}
	var optional []string
	for _, scope := range a.oauthScopes() {
		if slices.Contains(atprotooauth.OptionalScopes, scope) {
			optional = append(optional, scope)
		}
	}
	if len(optional) == 0 {
		return nil
	}
	return atprotooauth.MissingScopes(optional, granted)
}

func signedIn(status v1alpha1.MarketIdentityStatus, generation int64, did, handle, pdsURL string,
	ref *commonv1.SecretRef, token string) Result {
	status.Phase = v1alpha1.MarketIdentityPhaseReady
	status.ObservedGeneration = generation
	status.Did = strings.TrimSpace(did)
	status.Handle = strings.TrimSpace(handle)
	status.PdsURL = strings.TrimSpace(pdsURL)
	status.SessionRef = ref
	status.Associations = nil
	status.PendingAction = nil
	status.Message = ""
	conds.Set(&status.Conditions, generation, v1alpha1.ConditionSignedIn, true, "SignedIn", "the account session is live; the status carries no token")
	setReady(&status, generation, true, "SignedIn", status.Did)
	return Result{Status: status, RequeueAfter: refreshAfter(token)}
}

func oauthExpired(err error) bool {
	return errors.Is(err, oauthclient.ErrSessionExpired) || errors.Is(err, oauthclient.ErrNoRefreshToken)
}

func oauthSessionRef(id *v1alpha1.MarketIdentity, status *v1alpha1.MarketIdentityStatus) *commonv1.SecretRef {
	if id.Spec.SessionRef != nil {
		return id.Spec.SessionRef
	}
	return status.SessionRef
}

func signInRequestedAt(id *v1alpha1.MarketIdentity) time.Time {
	at, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(id.Spec.SignInRequestedAt))
	if err != nil {
		return time.Time{}
	}
	return at
}

func issuedAt(id *v1alpha1.MarketIdentity, now time.Time) time.Time {
	if at := signInRequestedAt(id); at.After(now) {
		return at
	}
	return now
}

func pendingExpiry(action *v1alpha1.MarketIdentityPendingAction, kind v1alpha1.PendingActionKind, now time.Time) (time.Time, bool) {
	if action == nil || action.Kind != kind || strings.TrimSpace(action.URL) == "" {
		return time.Time{}, false
	}
	if action.ExpiresAt == "" {
		return time.Time{}, true
	}
	expires, err := time.Parse(time.RFC3339, action.ExpiresAt)
	if err != nil || !now.Before(expires) {
		return time.Time{}, false
	}
	return expires, true
}

func untilOrZero(at, now time.Time) time.Duration {
	if at.IsZero() {
		return 0
	}
	return at.Sub(now)
}

func pendingMessage(phase v1alpha1.MarketIdentityPhase) string {
	switch phase {
	case v1alpha1.MarketIdentityPhaseSessionExpired:
		return "the account session expired; sign in again"
	case v1alpha1.MarketIdentityPhaseAwaitingAuthorization:
		return "open the visit url to authorize the account"
	case v1alpha1.MarketIdentityPhaseAwaitingScan:
		return "scan the qr url to sign in"
	}
	return string(phase)
}

func qrKey(workspace, name string) string {
	return workspace + "/" + name
}

func (a *Accounts) newQrState(key string, id *v1alpha1.MarketIdentity) (*qrTransfer, error) {
	did, signer, err := a.qrSigner()
	if err != nil {
		return nil, err
	}
	if strings.TrimSpace(a.oauth.QROrigin) == "" {
		return nil, errors.New("marketidentity: mode oauthQr needs an oauth qrOrigin")
	}
	nonce, err := oauthclient.NewQRNonce()
	if err != nil {
		return nil, err
	}
	transfer, err := oauthclient.NewQRTransfer(a.oauth.QROrigin, did, nonce, signer)
	if err != nil {
		return nil, err
	}
	transfer.HTTPClient = a.oauthHTTP()
	transfer.TTL = opt.Positive(transfer.TTL, oauthclient.DefaultQRTTL)
	qrURL, err := transfer.URL()
	if err != nil {
		return nil, err
	}
	now := time.Now()
	qr := &qrTransfer{transfer: transfer, url: qrURL, issued: issuedAt(id, now), expires: now.Add(transfer.TTL)}
	a.qr.SetTTL(key, qr, transfer.TTL)
	return qr, nil
}

func (a *Accounts) qrSigner() (string, oauthclient.ServiceAuthSigner, error) {
	if a.oauth.CLIKey != nil {
		public, err := a.oauth.CLIKey.PublicKey()
		if err != nil {
			return "", nil, err
		}
		did := public.DIDKey()
		return did, oauthclient.KeySigner{Issuer: syntax.DID(did), Key: a.oauth.CLIKey}, nil
	}
	if a.oauth.QRSigner != nil && strings.TrimSpace(a.oauth.CLIDID) != "" {
		return strings.TrimSpace(a.oauth.CLIDID), a.oauth.QRSigner, nil
	}
	return "", nil, errors.New("marketidentity: mode oauthQr needs a CLI key or a qr signer and cliDID")
}