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") }