package oauthclient import ( "bytes" "context" "crypto/rand" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "sync" "time" "github.com/bluesky-social/indigo/atproto/atcrypto" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/serviceauth" ) const ( QRNSID = "com.fedfork.atprotoOauthQR" QRNonceHeader = "x-oauth-qr-nonce" DefaultQRTTL = 5 * time.Minute QRServiceAuthTTL = time.Minute ) type ServiceAuthSigner interface { SignServiceAuth(ctx context.Context, audience, lxm string, ttl time.Duration) (string, error) } type ServiceAuthSignerFunc func(ctx context.Context, audience, lxm string, ttl time.Duration) (string, error) func (f ServiceAuthSignerFunc) SignServiceAuth(ctx context.Context, audience, lxm string, ttl time.Duration) (string, error) { if f == nil { return "", errors.New("oauthclient: nil service auth signer") } return f(ctx, audience, lxm, ttl) } type KeySigner struct { Issuer syntax.DID Key atcrypto.PrivateKey } func (s KeySigner) SignServiceAuth(ctx context.Context, audience, lxm string, ttl time.Duration) (string, error) { nsid, err := syntax.ParseNSID(lxm) if err != nil { return "", err } return serviceauth.Sign(s.Issuer, audience, nsid, s.Key, ttl) } type QRSession struct { AccessJWT string `json:"accessJwt"` RefreshJWT string `json:"refreshJwt"` UserDID string `json:"userDid"` Handle string `json:"handle"` PDS string `json:"pds"` DPoPPublicJWK map[string]any `json:"dpopPublicJwk"` DPoPPrivateJWK map[string]any `json:"dpopPrivateJwk"` Scope string `json:"scope,omitempty"` VerifiedDID string `json:"verifiedDid"` } func (q QRSession) GrantReported() bool { return strings.TrimSpace(q.Scope) != "" } type QRUnverifiedError struct { VerifiedDID string UserDID string } func (e *QRUnverifiedError) Error() string { if e.VerifiedDID == "" { return "oauthclient: the qr origin did not verify the transferred session of " + e.UserDID + " at the account PDS" } return fmt.Sprintf("oauthclient: the qr origin verified %s at the account PDS, but the transferred session names %s", e.VerifiedDID, e.UserDID) } func (q QRSession) Verified() error { verified, user := strings.TrimSpace(q.VerifiedDID), strings.TrimSpace(q.UserDID) if verified == "" || verified != user { return &QRUnverifiedError{VerifiedDID: verified, UserDID: user} } return nil } func QRClientID(origin string) string { origin = strings.TrimRight(strings.TrimSpace(origin), "/") if origin == "" { return "" } return origin + "/oauth-client-metadata.json" } func jwkString(jwk map[string]any, name string) string { value, _ := jwk[name].(string) return value } func (q QRSession) SessionData(clientID string, requested []string) (SessionData, error) { if q.UserDID == "" { return SessionData{}, errors.New("oauthclient: the qr session names no user DID") } did, err := syntax.ParseDID(q.UserDID) if err != nil { return SessionData{}, fmt.Errorf("oauthclient: the qr session user DID: %w", err) } if q.PDS == "" { return SessionData{}, errors.New("oauthclient: the qr session names no PDS") } pdsURL, err := url.Parse(q.PDS) if err != nil || pdsURL.Host == "" || (pdsURL.Scheme != "http" && pdsURL.Scheme != "https") { return SessionData{}, fmt.Errorf("oauthclient: the qr session PDS %q is not an absolute http or https URL", q.PDS) } host := strings.TrimRight(q.PDS, "/") key, err := privateKeyFromJWK(jwkString(q.DPoPPrivateJWK, "kty"), jwkString(q.DPoPPrivateJWK, "d")) if err != nil { return SessionData{}, err } scopes := append([]string(nil), requested...) if q.GrantReported() { if scopes, err = GrantedScopes(q.Scope); err != nil { return SessionData{}, fmt.Errorf("oauthclient: the qr session: %w", err) } } return SessionData{ AccountDID: did.String(), SessionID: SessionID(did.String(), host), HostURL: host, ClientID: strings.TrimSpace(clientID), Scopes: scopes, AccessToken: q.AccessJWT, RefreshToken: q.RefreshJWT, DPoPPrivateKeyMultibase: key.Multibase(), }, nil } type QRTransfer struct { Origin string CLIDID string Nonce string Signer ServiceAuthSigner HTTPClient *http.Client TTL time.Duration mu sync.Mutex consumed bool } func NewQRTransfer(origin, cliDID, nonce string, signer ServiceAuthSigner) (*QRTransfer, error) { parsed, err := url.Parse(origin) if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { return nil, fmt.Errorf("oauthclient: the qr origin %q is not an absolute http or https URL", origin) } did, err := syntax.ParseDID(cliDID) if err != nil { return nil, fmt.Errorf("oauthclient: the qr cli DID: %w", err) } if nonce == "" { return nil, errors.New("oauthclient: the qr transfer names no nonce") } if signer == nil { return nil, errors.New("oauthclient: the qr transfer has no service auth signer") } return &QRTransfer{ Origin: strings.TrimRight(parsed.String(), "/"), CLIDID: did.String(), Nonce: nonce, Signer: signer, TTL: DefaultQRTTL, }, nil } func NewQRNonce() (string, error) { raw := make([]byte, 24) if _, err := rand.Read(raw); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(raw), nil } func (q *QRTransfer) URL() (string, error) { if q == nil { return "", errors.New("oauthclient: no qr transfer") } return q.Origin + "/#oauth=" + url.QueryEscape(q.CLIDID) + "&n=" + url.QueryEscape(q.Nonce), nil } func (q *QRTransfer) audience() (string, error) { origin, err := url.Parse(q.Origin) if err != nil || origin.Hostname() == "" { return "", fmt.Errorf("oauthclient: the qr origin %q names no host", q.Origin) } return "did:web:" + strings.ToLower(origin.Hostname()), nil } func (q *QRTransfer) client() *http.Client { if q != nil && q.HTTPClient != nil { return q.HTTPClient } return &http.Client{Timeout: DefaultHTTPTimeout} } func (q *QRTransfer) PollOnce(ctx context.Context) (QRSession, bool, error) { if q == nil { return QRSession{}, false, errors.New("oauthclient: no qr transfer") } q.mu.Lock() consumed := q.consumed q.mu.Unlock() if consumed { return QRSession{}, false, ErrQRConsumed } audience, err := q.audience() if err != nil { return QRSession{}, false, err } token, err := q.Signer.SignServiceAuth(ctx, audience, QRNSID, QRServiceAuthTTL) if err != nil { return QRSession{}, false, fmt.Errorf("oauthclient: the qr service auth token: %w", err) } query := url.Values{"cliDid": {q.CLIDID}} req, err := http.NewRequestWithContext(ctx, http.MethodGet, q.Origin+"/xrpc/"+QRNSID+"?"+query.Encode(), nil) if err != nil { return QRSession{}, false, err } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Accept", "application/json") req.Header.Set(QRNonceHeader, q.Nonce) resp, err := q.client().Do(req) if err != nil { return QRSession{}, false, err } defer resp.Body.Close() raw, err := io.ReadAll(io.LimitReader(resp.Body, ReplyLimit)) if err != nil { return QRSession{}, false, err } switch { case resp.StatusCode == http.StatusOK: q.mu.Lock() q.consumed = true q.mu.Unlock() var session QRSession if err := json.Unmarshal(raw, &session); err != nil { return QRSession{}, false, fmt.Errorf("oauthclient: decoding the qr session: %w", err) } return session, true, nil case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusUnauthorized: return QRSession{}, false, nil default: return QRSession{}, false, fmt.Errorf("oauthclient: the qr poll returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) } } func (q *QRTransfer) Publish(ctx context.Context, session QRSession) error { if q == nil { return errors.New("oauthclient: no qr transfer") } body := map[string]any{ "cliDid": q.CLIDID, "nonce": q.Nonce, "accessJwt": session.AccessJWT, "refreshJwt": session.RefreshJWT, "userDid": session.UserDID, "handle": session.Handle, "pds": session.PDS, "dpopPublicJwk": session.DPoPPublicJWK, "dpopPrivateJwk": session.DPoPPrivateJWK, } if session.GrantReported() { body["scope"] = session.Scope } raw, err := json.Marshal(body) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, q.Origin+"/xrpc/"+QRNSID, bytes.NewReader(raw)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") resp, err := q.client().Do(req) if err != nil { return err } defer resp.Body.Close() reply, err := io.ReadAll(io.LimitReader(resp.Body, ReplyLimit)) if err != nil { return err } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("oauthclient: the qr publish returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(reply))) } return nil }