fork of indigo with slightly nicer lexgen

atproto SDK: general-purpose API client (#900)

This is a more-protocol-complete replacement for `xrpc.Client`. I've
iterated on the overall design a couple times and feel pretty confident
about this implementation. Features:

- `APIClient` for use with XRPC HTTP requests
- pluggable `AuthMethod` abstraction, with basic implementations for
admin auth and password auth sessions (other auth methods, like OAuth
and inter-service auth, will be separate packages)
- reusable `APIError` and `APIRequest` types
- handle corner-cases like retryable request bodies when auth method
needs to re-send requests
- test coverage

Design notes for reviewers:

- should this package be named `atproto/atclient` instead of
`atproto/client`? "client" is such a generic term
- plan is to have `atproto/auth` with both server and client
implementations of service auth; and then `atproto/auth/oauth` with
OAuth stuff
- observability: open to adding `slog` logging if desired. I'd be
resistant to adding things like otel traces or prometheus metrics; i'd
like to keep this base type minimal in terms of dependencies. could hook
those in via `http.Client`, or a wrapper type.
- some initialization methods return just a pointer to a struct (no
error). those could be returning the struct directly; not sure what our
house style should be for that

A couple small known limitations:

- one not-frequently-used protocol feature is to return the "last
indexed" repo rev in AppView responses, to help with read-after-write
and debugging. the `Get()` and `Post()` helpers don't have an easy way
to access that; could be saved on the `APIClient` struct itself?
- with password auth, there isn't an easy to way directly call
`Logout()`. it isn't so bad to type-unpack the `APIClient.Auth` when
needed? but alternatively the `DoWithAuth` method could detect any
`deleteSession` call and use the refresh token for that (instead of
access token); then calling code would just call that method. feels a
bit "magic" though.
- some headers, like `User-Agent`, don't get passed through and included
on some special calls, like token refresh. not sure how big a deal this
is

authored by bnewbold.net and committed by GitHub be0a9b5e 294ad377

+23
atproto/client/admin_auth.go
··· 1 + package client 2 + 3 + import ( 4 + "net/http" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + ) 8 + 9 + // Simple [AuthMethod] implementation for atproto "admin auth". 10 + type AdminAuth struct { 11 + Password string 12 + } 13 + 14 + func (a *AdminAuth) DoWithAuth(c *http.Client, req *http.Request, endpoint syntax.NSID) (*http.Response, error) { 15 + req.SetBasicAuth("admin", a.Password) 16 + return c.Do(req) 17 + } 18 + 19 + func NewAdminClient(host, password string) *APIClient { 20 + c := NewAPIClient(host) 21 + c.Auth = &AdminAuth{Password: password} 22 + return c 23 + }
+51
atproto/client/admin_auth_test.go
··· 1 + package client 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "net/http" 7 + "net/http/httptest" 8 + "testing" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + 12 + "github.com/stretchr/testify/assert" 13 + ) 14 + 15 + func adminHandler(w http.ResponseWriter, r *http.Request) { 16 + username, password, ok := r.BasicAuth() 17 + if ok && username == "admin" && password == "secret" { 18 + w.Header().Set("Content-Type", "application/json") 19 + fmt.Fprintln(w, "{\"status\":\"success\"}") 20 + return 21 + } 22 + w.Header().Set("WWW-Authenticate", `Basic realm="admin", charset="UTF-8"`) 23 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 24 + } 25 + 26 + func TestAdminAuth(t *testing.T) { 27 + assert := assert.New(t) 28 + ctx := context.Background() 29 + var apierr *APIError 30 + 31 + srv := httptest.NewServer(http.HandlerFunc(adminHandler)) 32 + defer srv.Close() 33 + 34 + { 35 + c := NewAPIClient(srv.URL) 36 + err := c.Get(ctx, syntax.NSID("com.example.get"), nil, nil) 37 + assert.ErrorAs(err, &apierr) 38 + } 39 + 40 + { 41 + c := NewAdminClient(srv.URL, "wrong") 42 + err := c.Get(ctx, syntax.NSID("com.example.get"), nil, nil) 43 + assert.ErrorAs(err, &apierr) 44 + } 45 + 46 + { 47 + c := NewAdminClient(srv.URL, "secret") 48 + err := c.Get(ctx, syntax.NSID("com.example.get"), nil, nil) 49 + assert.NoError(err) 50 + } 51 + }
+200
atproto/client/apiclient.go
··· 1 + package client 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + ) 13 + 14 + // Interface for auth implementations which can be used with [APIClient]. 15 + type AuthMethod interface { 16 + // Endpoint parameter is included for auth methods which need to include the NSID in authorization tokens 17 + DoWithAuth(c *http.Client, req *http.Request, endpoint syntax.NSID) (*http.Response, error) 18 + } 19 + 20 + // General purpose client for atproto "XRPC" API endpoints. 21 + type APIClient struct { 22 + // Inner HTTP client. May be customized after the overall [APIClient] struct is created; for example to set a default request timeout. 23 + Client *http.Client 24 + 25 + // Host URL prefix: scheme, hostname, and port. This field is required. 26 + Host string 27 + 28 + // Optional auth client "middleware". 29 + Auth AuthMethod 30 + 31 + // Optional HTTP headers which will be included in all requests. Only a single value per key is included; request-level headers will override any client-level defaults. 32 + Headers http.Header 33 + 34 + // optional authenticated account DID for this client. Does not change client behavior; this field is included as a convenience for calling code, logging, etc. 35 + AccountDID *syntax.DID 36 + } 37 + 38 + // Creates a simple APIClient for the provided host. This is appropriate for use with unauthenticated ("public") atproto API endpoints, or to use as a base client to add authentication. 39 + // 40 + // Uses [http.DefaultClient], and sets a default User-Agent. 41 + func NewAPIClient(host string) *APIClient { 42 + return &APIClient{ 43 + Client: http.DefaultClient, 44 + Host: host, 45 + Headers: map[string][]string{ 46 + "User-Agent": []string{"indigo-sdk"}, 47 + }, 48 + } 49 + } 50 + 51 + // High-level helper for simple JSON "Query" API calls. 52 + // 53 + // This method automatically parses non-successful responses to [APIError]. 54 + // 55 + // For Query endpoints which return non-JSON data, or other situations needing complete configuration of the request and response, use the [APIClient.Do] method. 56 + func (c *APIClient) Get(ctx context.Context, endpoint syntax.NSID, params map[string]any, out any) error { 57 + 58 + req := NewAPIRequest(http.MethodGet, endpoint, nil) 59 + req.Headers.Set("Accept", "application/json") 60 + 61 + if params != nil { 62 + qp, err := ParseParams(params) 63 + if err != nil { 64 + return err 65 + } 66 + req.QueryParams = qp 67 + } 68 + 69 + resp, err := c.Do(ctx, req) 70 + if err != nil { 71 + return err 72 + } 73 + defer resp.Body.Close() 74 + 75 + if !(resp.StatusCode >= 200 && resp.StatusCode < 300) { 76 + var eb ErrorBody 77 + if err := json.NewDecoder(resp.Body).Decode(&eb); err != nil { 78 + return &APIError{StatusCode: resp.StatusCode} 79 + } 80 + return eb.APIError(resp.StatusCode) 81 + } 82 + 83 + if out == nil { 84 + // drain body before returning 85 + io.ReadAll(resp.Body) 86 + return nil 87 + } 88 + 89 + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { 90 + return fmt.Errorf("failed decoding JSON response body: %w", err) 91 + } 92 + return nil 93 + } 94 + 95 + // High-level helper for simple JSON-to-JSON "Procedure" API calls, with no query params. 96 + // 97 + // This method automatically parses non-successful responses to [APIError]. 98 + // 99 + // For Query endpoints which expect non-JSON request bodies; return non-JSON responses; direct use of [io.Reader] for the request body; or other situations needing complete configuration of the request and response, use the [APIClient.Do] method. 100 + func (c *APIClient) Post(ctx context.Context, endpoint syntax.NSID, body any, out any) error { 101 + bodyJSON, err := json.Marshal(body) 102 + if err != nil { 103 + return err 104 + } 105 + 106 + req := NewAPIRequest(http.MethodPost, endpoint, bytes.NewReader(bodyJSON)) 107 + req.Headers.Set("Accept", "application/json") 108 + req.Headers.Set("Content-Type", "application/json") 109 + 110 + resp, err := c.Do(ctx, req) 111 + if err != nil { 112 + return err 113 + } 114 + defer resp.Body.Close() 115 + 116 + if !(resp.StatusCode >= 200 && resp.StatusCode < 300) { 117 + var eb ErrorBody 118 + if err := json.NewDecoder(resp.Body).Decode(&eb); err != nil { 119 + return &APIError{StatusCode: resp.StatusCode} 120 + } 121 + return eb.APIError(resp.StatusCode) 122 + } 123 + 124 + if out == nil { 125 + // drain body before returning 126 + io.ReadAll(resp.Body) 127 + return nil 128 + } 129 + 130 + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { 131 + return fmt.Errorf("failed decoding JSON response body: %w", err) 132 + } 133 + return nil 134 + } 135 + 136 + // Full-featured method for atproto API requests. 137 + // 138 + // TODO: this does not currently parse API error response JSON body to [APIError], thought it might in the future. 139 + func (c *APIClient) Do(ctx context.Context, req *APIRequest) (*http.Response, error) { 140 + 141 + if c.Client == nil { 142 + c.Client = http.DefaultClient 143 + } 144 + 145 + httpReq, err := req.HTTPRequest(ctx, c.Host, c.Headers) 146 + if err != nil { 147 + return nil, err 148 + } 149 + 150 + var resp *http.Response 151 + if c.Auth != nil { 152 + resp, err = c.Auth.DoWithAuth(c.Client, httpReq, req.Endpoint) 153 + } else { 154 + resp, err = c.Client.Do(httpReq) 155 + } 156 + if err != nil { 157 + return nil, err 158 + } 159 + return resp, nil 160 + } 161 + 162 + // Returns a shallow copy of the APIClient with the provided service ref configured as a proxy header. 163 + // 164 + // To configure service proxying without creating a copy, simply set the 'Atproto-Proxy' header. 165 + func (c *APIClient) WithService(ref string) *APIClient { 166 + hdr := c.Headers.Clone() 167 + hdr.Set("Atproto-Proxy", ref) 168 + out := APIClient{ 169 + Client: c.Client, 170 + Host: c.Host, 171 + Auth: c.Auth, 172 + Headers: hdr, 173 + AccountDID: c.AccountDID, 174 + } 175 + return &out 176 + } 177 + 178 + // Configures labeler header ('Atproto-Accept-Labelers') with the indicated "redact" level labelers, and regular labelers. 179 + // 180 + // Overwrites any existing client-level header value. 181 + func (c *APIClient) SetLabelers(redact, other []syntax.DID) { 182 + c.Headers.Set("Atproto-Accept-Labelers", encodeLabelerHeader(redact, other)) 183 + } 184 + 185 + func encodeLabelerHeader(redact, other []syntax.DID) string { 186 + val := "" 187 + for _, did := range redact { 188 + if val != "" { 189 + val = val + "," 190 + } 191 + val = fmt.Sprintf("%s%s;redact", val, did.String()) 192 + } 193 + for _, did := range other { 194 + if val != "" { 195 + val = val + "," 196 + } 197 + val = val + did.String() 198 + } 199 + return val 200 + }
+21
atproto/client/apiclient_test.go
··· 1 + package client 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/bluesky-social/indigo/atproto/syntax" 7 + 8 + "github.com/stretchr/testify/assert" 9 + ) 10 + 11 + func TestEncodeLabelerHeader(t *testing.T) { 12 + assert := assert.New(t) 13 + 14 + labelerA := syntax.DID("did:web:aaa.example.com") 15 + labelerB := syntax.DID("did:web:bbb.example.com") 16 + 17 + assert.Equal("", encodeLabelerHeader(nil, nil)) 18 + assert.Equal("did:web:aaa.example.com,did:web:bbb.example.com", encodeLabelerHeader(nil, []syntax.DID{labelerA, labelerB})) 19 + assert.Equal("did:web:aaa.example.com;redact,did:web:bbb.example.com", encodeLabelerHeader([]syntax.DID{labelerA}, []syntax.DID{labelerB})) 20 + assert.Equal("did:web:aaa.example.com;redact", encodeLabelerHeader([]syntax.DID{labelerA}, nil)) 21 + }
+36
atproto/client/apierror.go
··· 1 + package client 2 + 3 + import ( 4 + "fmt" 5 + ) 6 + 7 + type APIError struct { 8 + StatusCode int 9 + Name string 10 + Message string 11 + } 12 + 13 + func (ae *APIError) Error() string { 14 + if ae.StatusCode > 0 { 15 + if ae.Name != "" && ae.Message != "" { 16 + return fmt.Sprintf("API request failed (HTTP %d): %s: %s", ae.StatusCode, ae.Name, ae.Message) 17 + } else if ae.Name != "" { 18 + return fmt.Sprintf("API request failed (HTTP %d): %s", ae.StatusCode, ae.Name) 19 + } 20 + return fmt.Sprintf("API request failed (HTTP %d)", ae.StatusCode) 21 + } 22 + return "API request failed" 23 + } 24 + 25 + type ErrorBody struct { 26 + Name string `json:"error"` 27 + Message string `json:"message,omitempty"` 28 + } 29 + 30 + func (eb *ErrorBody) APIError(statusCode int) error { 31 + return &APIError{ 32 + StatusCode: statusCode, 33 + Name: eb.Name, 34 + Message: eb.Message, 35 + } 36 + }
+117
atproto/client/apirequest.go
··· 1 + package client 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "io" 7 + "net/http" 8 + "net/url" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + ) 12 + 13 + var ( 14 + // atproto API "Query" Lexicon method, which is HTTP GET. Not to be confused with the IETF draft "HTTP QUERY" method. 15 + MethodQuery = http.MethodGet 16 + 17 + // atproto API "Procedure" Lexicon method, which is HTTP POST. 18 + MethodProcedure = http.MethodPost 19 + ) 20 + 21 + type APIRequest struct { 22 + // HTTP method as a string (eg "GET") (required) 23 + Method string 24 + 25 + // atproto API endpoint, as NSID (required) 26 + Endpoint syntax.NSID 27 + 28 + // Optional request body (may be nil). If this is provided, then 'Content-Type' header should be specified 29 + Body io.Reader 30 + 31 + // Optional function to return new reader for request body; used for retries. Strongly recommended if Body is defined. Body still needs to be defined, even if this function is provided. 32 + GetBody func() (io.ReadCloser, error) 33 + 34 + // Optional query parameters (field may be nil). These will be encoded as provided. 35 + QueryParams url.Values 36 + 37 + // Optional HTTP headers (field bay be nil). Only the first value will be included for each header key ("Set" behavior). 38 + Headers http.Header 39 + } 40 + 41 + // Initializes a new request struct. Initializes Headers and QueryParams so they can be manipulated immediately. 42 + // 43 + // If body is provided (it can be nil), will try to turn it in to the most retry-able form (and wrap as [io.ReadCloser]). 44 + func NewAPIRequest(method string, endpoint syntax.NSID, body io.Reader) *APIRequest { 45 + req := APIRequest{ 46 + Method: method, 47 + Endpoint: endpoint, 48 + Headers: map[string][]string{}, 49 + QueryParams: map[string][]string{}, 50 + } 51 + 52 + // logic to turn "whatever io.Reader we are handed" in to something relatively re-tryable (using GetBody) 53 + if body != nil { 54 + // NOTE: http.NewRequestWithContext already handles GetBody() as well as ContentLength for specific types like bytes.Buffer and strings.Reader. We just want to add io.Seeker here, for things like files-on-disk. 55 + switch v := body.(type) { 56 + case io.Seeker: 57 + req.Body = io.NopCloser(body) 58 + req.GetBody = func() (io.ReadCloser, error) { 59 + v.Seek(0, 0) 60 + return io.NopCloser(body), nil 61 + } 62 + default: 63 + req.Body = body 64 + } 65 + } 66 + return &req 67 + } 68 + 69 + // Creates an [http.Request] for this API request. 70 + // 71 + // `host` parameter should be a URL prefix: schema, hostname, port (required) 72 + // 73 + // `clientHeaders`, if provided, is treated as client-level defaults. Only a single value is allowed per key ("Set" behavior), and will be clobbered by any request-level header values. (optional; may be nil) 74 + func (r *APIRequest) HTTPRequest(ctx context.Context, host string, clientHeaders http.Header) (*http.Request, error) { 75 + u, err := url.Parse(host) 76 + if err != nil { 77 + return nil, err 78 + } 79 + if u.Host == "" { 80 + return nil, fmt.Errorf("empty hostname in host URL") 81 + } 82 + if u.Scheme == "" { 83 + return nil, fmt.Errorf("empty scheme in host URL") 84 + } 85 + if r.Endpoint == "" { 86 + return nil, fmt.Errorf("empty request endpoint") 87 + } 88 + u.Path = "/xrpc/" + r.Endpoint.String() 89 + u.RawQuery = "" 90 + if r.QueryParams != nil && len(r.QueryParams) > 0 { 91 + u.RawQuery = r.QueryParams.Encode() 92 + } 93 + httpReq, err := http.NewRequestWithContext(ctx, r.Method, u.String(), r.Body) 94 + if err != nil { 95 + return nil, err 96 + } 97 + 98 + if r.GetBody != nil { 99 + httpReq.GetBody = r.GetBody 100 + } 101 + 102 + // first set default headers... 103 + if clientHeaders != nil { 104 + for k := range clientHeaders { 105 + httpReq.Header.Set(k, clientHeaders.Get(k)) 106 + } 107 + } 108 + 109 + // ... then request-specific take priority (overwrite) 110 + if r.Headers != nil { 111 + for k := range r.Headers { 112 + httpReq.Header.Set(k, r.Headers.Get(k)) 113 + } 114 + } 115 + 116 + return httpReq, nil 117 + }
+246
atproto/client/cmd/atp-client-demo/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log/slog" 8 + "os" 9 + 10 + comatproto "github.com/bluesky-social/indigo/api/agnostic" 11 + "github.com/bluesky-social/indigo/atproto/client" 12 + "github.com/bluesky-social/indigo/atproto/identity" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + 15 + "github.com/urfave/cli/v2" 16 + ) 17 + 18 + func main() { 19 + app := cli.App{ 20 + Name: "atp-client-demo", 21 + Usage: "dev helper for atproto/client SDK", 22 + Commands: []*cli.Command{ 23 + &cli.Command{ 24 + Name: "get-feed-public", 25 + Usage: "do a basic GET request (getAuthorFeed)", 26 + Action: runGetFeedPublic, 27 + Flags: []cli.Flag{ 28 + &cli.StringFlag{ 29 + Name: "host", 30 + Value: "https://public.api.bsky.app", 31 + Usage: "service host", 32 + }, 33 + }, 34 + }, 35 + &cli.Command{ 36 + Name: "list-records-public", 37 + Usage: "do a basic GET request (listRecords)", 38 + Action: runListRecordsPublic, 39 + Flags: []cli.Flag{ 40 + &cli.StringFlag{ 41 + Name: "host", 42 + Value: "https://enoki.us-east.host.bsky.network", 43 + Usage: "service host", 44 + }, 45 + }, 46 + }, 47 + &cli.Command{ 48 + Name: "login-auth", 49 + Usage: "do a basic login and GET session info", 50 + Action: runLoginAuth, 51 + Flags: []cli.Flag{ 52 + &cli.StringFlag{ 53 + Name: "username", 54 + Required: true, 55 + Aliases: []string{"u"}, 56 + Usage: "handle or DID (not email)", 57 + }, 58 + &cli.StringFlag{ 59 + Name: "password", 60 + Required: true, 61 + Aliases: []string{"p"}, 62 + Usage: "password (or app password)", 63 + }, 64 + }, 65 + }, 66 + &cli.Command{ 67 + Name: "get-feed-auth", 68 + Usage: "basic authenticated GET request", 69 + Action: runGetFeedAuth, 70 + Flags: []cli.Flag{ 71 + &cli.StringFlag{ 72 + Name: "username", 73 + Required: true, 74 + Aliases: []string{"u"}, 75 + Usage: "handle or DID (not email)", 76 + }, 77 + &cli.StringFlag{ 78 + Name: "password", 79 + Required: true, 80 + Aliases: []string{"p"}, 81 + Usage: "password (or app password)", 82 + }, 83 + &cli.StringFlag{ 84 + Name: "labelers", 85 + }, 86 + &cli.StringFlag{ 87 + Name: "appview", 88 + Value: "did:web:api.bsky.app#bsky_appview", 89 + Usage: "bsky appview service DID ref", 90 + }, 91 + }, 92 + }, 93 + &cli.Command{ 94 + Name: "lookup-admin", 95 + Usage: "basic PDS admin auth request (getAccountInfo)", 96 + Action: runLookupAdmin, 97 + Flags: []cli.Flag{ 98 + &cli.StringFlag{ 99 + Name: "admin-password", 100 + Required: true, 101 + Aliases: []string{"p"}, 102 + Usage: "admin auth password", 103 + }, 104 + &cli.StringFlag{ 105 + Name: "host", 106 + Required: true, 107 + Usage: "service host", 108 + }, 109 + &cli.StringFlag{ 110 + Name: "did", 111 + Required: true, 112 + Usage: "account DID to lookup", 113 + }, 114 + }, 115 + }, 116 + }, 117 + } 118 + h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}) 119 + slog.SetDefault(slog.New(h)) 120 + app.RunAndExitOnError() 121 + } 122 + 123 + func getFeed(ctx context.Context, c *client.APIClient) error { 124 + params := map[string]any{ 125 + "actor": "atproto.com", 126 + "limit": 2, 127 + "includePins": false, 128 + } 129 + 130 + var d json.RawMessage 131 + err := c.Get(ctx, "app.bsky.feed.getAuthorFeed", params, &d) 132 + if err != nil { 133 + return err 134 + } 135 + 136 + out, err := json.MarshalIndent(d, "", " ") 137 + if err != nil { 138 + return err 139 + } 140 + fmt.Println(string(out)) 141 + return nil 142 + } 143 + 144 + func listRecords(ctx context.Context, c *client.APIClient) error { 145 + 146 + list, err := comatproto.RepoListRecords(ctx, c, "app.bsky.actor.profile", "", 10, "did:plc:ewvi7nxzyoun6zhxrhs64oiz", false) 147 + if err != nil { 148 + return err 149 + } 150 + 151 + out, err := json.MarshalIndent(list, "", " ") 152 + if err != nil { 153 + return err 154 + } 155 + fmt.Println(string(out)) 156 + return nil 157 + } 158 + 159 + func runGetFeedPublic(cctx *cli.Context) error { 160 + ctx := cctx.Context 161 + 162 + c := client.APIClient{ 163 + Host: cctx.String("host"), 164 + } 165 + 166 + return getFeed(ctx, &c) 167 + } 168 + 169 + func runListRecordsPublic(cctx *cli.Context) error { 170 + ctx := cctx.Context 171 + 172 + c := client.APIClient{ 173 + Host: cctx.String("host"), 174 + } 175 + 176 + return listRecords(ctx, &c) 177 + } 178 + 179 + func runLoginAuth(cctx *cli.Context) error { 180 + ctx := cctx.Context 181 + 182 + atid, err := syntax.ParseAtIdentifier(cctx.String("username")) 183 + if err != nil { 184 + return err 185 + } 186 + 187 + dir := identity.DefaultDirectory() 188 + 189 + c, err := client.LoginWithPassword(ctx, dir, *atid, cctx.String("password"), "", nil) 190 + if err != nil { 191 + return err 192 + } 193 + 194 + var d json.RawMessage 195 + err = c.Get(ctx, "com.atproto.server.getSession", nil, &d) 196 + if err != nil { 197 + return err 198 + } 199 + 200 + out, err := json.MarshalIndent(d, "", " ") 201 + if err != nil { 202 + return err 203 + } 204 + fmt.Println(string(out)) 205 + return nil 206 + } 207 + 208 + func runGetFeedAuth(cctx *cli.Context) error { 209 + ctx := cctx.Context 210 + 211 + atid, err := syntax.ParseAtIdentifier(cctx.String("username")) 212 + if err != nil { 213 + return err 214 + } 215 + 216 + dir := identity.DefaultDirectory() 217 + 218 + c, err := client.LoginWithPassword(ctx, dir, *atid, cctx.String("password"), "", nil) 219 + if err != nil { 220 + return err 221 + } 222 + c = c.WithService(cctx.String("appview")) 223 + 224 + return getFeed(ctx, c) 225 + } 226 + 227 + func runLookupAdmin(cctx *cli.Context) error { 228 + ctx := cctx.Context 229 + 230 + c := client.NewAdminClient(cctx.String("host"), cctx.String("admin-password")) 231 + 232 + var d json.RawMessage 233 + params := map[string]any{ 234 + "did": cctx.String("did"), 235 + } 236 + if err := c.Get(ctx, "com.atproto.admin.getAccountInfo", params, &d); err != nil { 237 + return err 238 + } 239 + 240 + out, err := json.MarshalIndent(d, "", " ") 241 + if err != nil { 242 + return err 243 + } 244 + fmt.Println(string(out)) 245 + return nil 246 + }
+21
atproto/client/doc.go
··· 1 + /* 2 + General-purpose client for atproto "XRPC" HTTP API endpoints. 3 + 4 + [APIClient] wraps an [http.Client] and provides an ergonomic atproto-specific (but not Lexicon-specific) interface for "Query" (GET) and "Procedure" (POST) endpoints. It does not support "Event Stream" (WebSocket) endpoints. The client is expected to be used with a single host at a time, though it does have special support ([APIClient.WithService]) for proxied service requests when connected to a PDS host. The client does not authenticate requests by default, but supports pluggable authentication methods (see below). The [APIReponse] struct represents a generic API request, and helps with conversion to an [http.Request]. 5 + 6 + The [APIError] struct can represent a generic API error response (eg, an HTTP response with a 4xx or 5xx response code), including the 'error' and 'message' JSON response fields expected with atproto. It is intended to be used with [errors.Is] in error handling, or to provide helpful error messages. 7 + 8 + The [AuthMethod] interface allows [APIClient] to work with multiple forms of authentication in atproto. It is expected that more complex auth systems (eg, those using signed JWTs) will be implemented in separate packages, but this package does include two simple auth methods: 9 + 10 + - [PasswordAuth] is the original PDS user auth method, using access and refresh tokens. 11 + - [AdminAuth] is simple HTTP Basic authentication for administrative requests, as implemented by many atproto services (Relay, Ozone, PDS, etc). 12 + 13 + ## Design Notes 14 + 15 + Several [AuthMethod] implementations are expected to require retrying entire request at unexpected times. For example, unexpected OAuth DPoP nonce changes, or unexpected password session token refreshes. The auth method may also need to make requests to other servers as part of the refresh process (eg, OAuth when working with a PDS/entryway split). This means that requests should be "retryable" as often as possible. This is mostly a concern for Procedures (HTTP POST) with a non-empty body. The [http.Client] will attempt to "unclose" some common [io.ReadCloser] types (like [bytes.Buffer]), but others may need special handling, using the [APIRequest.GetBody] method. This package will try to make types implementing [io.Seeker] tryable; this helps with things like passing in a open file descriptor for file uploads. 16 + 17 + In theory, the [http.RoundTripper] interface could have been used instead of [AuthMethod]; or auth methods could have been injected in to [http.Client] instances directly. This package avoids this pattern for a few reasons. The first is that wrangling layered stacks of [http.RoundTripper] can become cumbersome. Calling code may want to use [http.Client] variants which add observability, retries, circuit-breaking, or other non-auth customization. Secondly, some atproto auth methods will require requests to other servers or endpoints, and having a common [http.Client] to re-use for these requests makes sense. Finally, several atproto auth methods need to know the target endpoint as an NSID; while this could be re-parsed from the request URL, it is simpler and more reliable to pass it as an argument. 18 + 19 + This package tries to use minimal dependencies beyond the Go standard library, to make it easy to reference as a dependency. It does require the [github.com/bluesky-social/indigo/atproto/syntax] and [github.com/bluesky-social/indigo/atproto/identity] sibling packages. In particular, this package does not include any auth methods requiring JWTs, to avoid adding any specific JWT implementation as a dependency. 20 + */ 21 + package client
+30
atproto/client/examples_test.go
··· 1 + package client 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + 7 + appbsky "github.com/bluesky-social/indigo/api/bsky" 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + ) 10 + 11 + func ExampleGet() { 12 + 13 + ctx := context.Background() 14 + 15 + c := APIClient{ 16 + Host: "https://public.api.bsky.app", 17 + } 18 + 19 + endpoint := syntax.NSID("app.bsky.actor.getProfile") 20 + params := map[string]any{ 21 + "actor": "atproto.com", 22 + } 23 + var profile appbsky.ActorDefs_ProfileViewDetailed 24 + if err := c.Get(ctx, endpoint, params, &profile); err != nil { 25 + panic(err) 26 + } 27 + 28 + fmt.Println(profile.Handle) 29 + // Output: atproto.com 30 + }
+92
atproto/client/lexclient.go
··· 1 + package client 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + 10 + "github.com/bluesky-social/indigo/atproto/syntax" 11 + ) 12 + 13 + // Implements the [github.com/bluesky-social/indigo/lex/util.LexClient] interface, for use with code-generated API helpers. 14 + func (c *APIClient) LexDo(ctx context.Context, method string, inputEncoding string, endpoint string, params map[string]any, bodyData any, out any) error { 15 + // some of the code here is copied from indigo:xrpc/xrpc.go 16 + 17 + nsid, err := syntax.ParseNSID(endpoint) 18 + if err != nil { 19 + return err 20 + } 21 + 22 + var body io.Reader 23 + if bodyData != nil { 24 + if rr, ok := bodyData.(io.Reader); ok { 25 + body = rr 26 + } else { 27 + b, err := json.Marshal(bodyData) 28 + if err != nil { 29 + return err 30 + } 31 + 32 + body = bytes.NewReader(b) 33 + if inputEncoding == "" { 34 + inputEncoding = "application/json" 35 + } 36 + } 37 + } 38 + 39 + req := NewAPIRequest(method, nsid, body) 40 + 41 + if inputEncoding != "" { 42 + req.Headers.Set("Content-Type", inputEncoding) 43 + } 44 + 45 + if params != nil { 46 + qp, err := ParseParams(params) 47 + if err != nil { 48 + return err 49 + } 50 + req.QueryParams = qp 51 + } 52 + 53 + resp, err := c.Do(ctx, req) 54 + if err != nil { 55 + return err 56 + } 57 + defer resp.Body.Close() 58 + 59 + if !(resp.StatusCode >= 200 && resp.StatusCode < 300) { 60 + var eb ErrorBody 61 + if err := json.NewDecoder(resp.Body).Decode(&eb); err != nil { 62 + return &APIError{StatusCode: resp.StatusCode} 63 + } 64 + return eb.APIError(resp.StatusCode) 65 + } 66 + 67 + if out == nil { 68 + // drain body before returning 69 + io.ReadAll(resp.Body) 70 + return nil 71 + } 72 + 73 + if buf, ok := out.(*bytes.Buffer); ok { 74 + if resp.ContentLength < 0 { 75 + _, err := io.Copy(buf, resp.Body) 76 + if err != nil { 77 + return fmt.Errorf("reading response body: %w", err) 78 + } 79 + } else { 80 + n, err := io.CopyN(buf, resp.Body, resp.ContentLength) 81 + if err != nil { 82 + return fmt.Errorf("reading length delimited response body (%d < %d): %w", n, resp.ContentLength, err) 83 + } 84 + } 85 + } else { 86 + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { 87 + return fmt.Errorf("failed decoding JSON response body: %w", err) 88 + } 89 + } 90 + 91 + return nil 92 + }
+42
atproto/client/params.go
··· 1 + package client 2 + 3 + import ( 4 + "encoding" 5 + "fmt" 6 + "net/url" 7 + "reflect" 8 + ) 9 + 10 + // Flexibly parses an input map to URL query params (strings) 11 + func ParseParams(raw map[string]any) (url.Values, error) { 12 + out := make(url.Values) 13 + for k := range raw { 14 + switch v := raw[k].(type) { 15 + case nil: 16 + out.Set(k, "") 17 + case bool, string, int, uint, int8, int16, int32, int64, uint8, uint16, uint32, uint64, uintptr: 18 + out.Set(k, fmt.Sprint(v)) 19 + case encoding.TextMarshaler: 20 + out.Set(k, fmt.Sprint(v)) 21 + default: 22 + ref := reflect.ValueOf(v) 23 + if ref.Kind() == reflect.Slice { 24 + for i := 0; i < ref.Len(); i++ { 25 + switch elem := ref.Index(i).Interface().(type) { 26 + case nil: 27 + out.Add(k, "") 28 + case bool, string, int, uint, int8, int16, int32, int64, uint8, uint16, uint32, uint64, uintptr: 29 + out.Add(k, fmt.Sprint(elem)) 30 + case encoding.TextMarshaler: 31 + out.Add(k, fmt.Sprint(elem)) 32 + default: 33 + return nil, fmt.Errorf("can't marshal query param '%s' with type: %T", k, v) 34 + } 35 + } 36 + } else { 37 + return nil, fmt.Errorf("can't marshal query param '%s' with type: %T", k, v) 38 + } 39 + } 40 + } 41 + return out, nil 42 + }
+50
atproto/client/params_test.go
··· 1 + package client 2 + 3 + import ( 4 + "net/url" 5 + "testing" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + 9 + "github.com/stretchr/testify/assert" 10 + "github.com/stretchr/testify/require" 11 + ) 12 + 13 + func TestParseParams(t *testing.T) { 14 + assert := assert.New(t) 15 + require := require.New(t) 16 + 17 + { 18 + input := map[string]any{ 19 + "int": int(-1), 20 + "uint32": uint32(32), 21 + "str": "hello", 22 + "bool": true, 23 + "did": syntax.DID("did:web:example.com"), 24 + "multiBool": []bool{true, false}, 25 + "multiDID": []syntax.DID{syntax.DID("did:web:example.com"), syntax.DID("did:web:other.com")}, 26 + } 27 + expect := url.Values(map[string][]string{ 28 + "int": []string{"-1"}, 29 + "uint32": []string{"32"}, 30 + "str": []string{"hello"}, 31 + "bool": []string{"true"}, 32 + "did": []string{"did:web:example.com"}, 33 + "multiBool": []string{"true", "false"}, 34 + "multiDID": []string{"did:web:example.com", "did:web:other.com"}, 35 + }) 36 + output, err := ParseParams(input) 37 + require.NoError(err) 38 + assert.Equal(expect, output) 39 + } 40 + 41 + { 42 + // unsupported type 43 + input := map[string]any{ 44 + "map": map[string]int{"a": 123}, 45 + } 46 + _, err := ParseParams(input) 47 + assert.Error(err) 48 + } 49 + 50 + }
+303
atproto/client/password_auth.go
··· 1 + package client 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "strings" 9 + "sync" 10 + 11 + "github.com/bluesky-social/indigo/atproto/identity" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + ) 14 + 15 + type RefreshCallback = func(ctx context.Context, data PasswordSessionData) 16 + 17 + // Implementation of [AuthMethod] for password-based auth sessions with atproto PDS hosts. Automatically refreshes "access token" using a "refresh token" when needed. 18 + // 19 + // It is safe to use this auth method concurrently from multiple goroutines. 20 + type PasswordAuth struct { 21 + Session PasswordSessionData 22 + 23 + // Optional callback function which gets called with updated session data whenever a successful token refresh happens. 24 + // 25 + // Note that this function is called while a lock is being held on the overall client, and with a context usually tied to a regular API request call. The callback should either return quickly, or spawn a goroutine. Because of the lock, this callback will never be called concurrently for a single client, but may be called currently across clients. 26 + RefreshCallback RefreshCallback 27 + 28 + // Lock which protects concurrent access to AccessToken and RefreshToken in session data. Note that this only applies to this particular instance of PasswordAuth. 29 + lk sync.RWMutex 30 + } 31 + 32 + // Data about a PDS password auth session which can be persisted and then used to resume the session later. 33 + type PasswordSessionData struct { 34 + AccessToken string `json:"access_token"` 35 + RefreshToken string `json:"refresh_token"` 36 + AccountDID syntax.DID `json:"account_did"` 37 + Host string `json:"host"` 38 + } 39 + 40 + // Creates a deep copy of the session data. 41 + func (sd *PasswordSessionData) Clone() PasswordSessionData { 42 + return PasswordSessionData{ 43 + AccessToken: sd.AccessToken, 44 + RefreshToken: sd.RefreshToken, 45 + AccountDID: sd.AccountDID, 46 + Host: sd.Host, 47 + } 48 + } 49 + 50 + type createSessionRequest struct { 51 + //AllowTakendown *bool `json:"allowTakendown,omitempty" cborgen:"allowTakendown,omitempty"` 52 + AuthFactorToken *string `json:"authFactorToken,omitempty" cborgen:"authFactorToken,omitempty"` 53 + // identifier: Handle or other identifier supported by the server for the authenticating user. 54 + Identifier string `json:"identifier" cborgen:"identifier"` 55 + Password string `json:"password" cborgen:"password"` 56 + } 57 + 58 + type createSessionResponse struct { 59 + AccessJwt string `json:"accessJwt" cborgen:"accessJwt"` 60 + Active *bool `json:"active,omitempty" cborgen:"active,omitempty"` 61 + Did string `json:"did" cborgen:"did"` 62 + //Email *string `json:"email,omitempty" cborgen:"email,omitempty"` 63 + //EmailAuthFactor *bool `json:"emailAuthFactor,omitempty" cborgen:"emailAuthFactor,omitempty"` 64 + //EmailConfirmed *bool `json:"emailConfirmed,omitempty" cborgen:"emailConfirmed,omitempty"` 65 + //Handle string `json:"handle" cborgen:"handle"` 66 + RefreshJwt string `json:"refreshJwt" cborgen:"refreshJwt"` 67 + Status *string `json:"status,omitempty" cborgen:"status,omitempty"` 68 + } 69 + 70 + type refreshSessionResponse struct { 71 + AccessJwt string `json:"accessJwt" cborgen:"accessJwt"` 72 + Active *bool `json:"active,omitempty" cborgen:"active,omitempty"` 73 + Did string `json:"did" cborgen:"did"` 74 + //Handle string `json:"handle" cborgen:"handle"` 75 + RefreshJwt string `json:"refreshJwt" cborgen:"refreshJwt"` 76 + Status *string `json:"status,omitempty" cborgen:"status,omitempty"` 77 + } 78 + 79 + func (a *PasswordAuth) DoWithAuth(c *http.Client, req *http.Request, endpoint syntax.NSID) (*http.Response, error) { 80 + accessToken, refreshToken := a.GetTokens() 81 + req.Header.Set("Authorization", "Bearer "+accessToken) 82 + resp, err := c.Do(req) 83 + if err != nil { 84 + return nil, err 85 + } 86 + 87 + // on success, or most errors, just return HTTP response 88 + if resp.StatusCode != http.StatusBadRequest || !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") { 89 + return resp, nil 90 + } 91 + 92 + // parse the error response body (JSON) and check the error name 93 + defer resp.Body.Close() 94 + var eb ErrorBody 95 + if err := json.NewDecoder(resp.Body).Decode(&eb); err != nil { 96 + return nil, &APIError{StatusCode: resp.StatusCode} 97 + } 98 + if eb.Name != "ExpiredToken" { 99 + return nil, eb.APIError(resp.StatusCode) 100 + } 101 + 102 + // ok, we had an expired token, try a refresh 103 + if err := a.Refresh(req.Context(), c, refreshToken); err != nil { 104 + return nil, err 105 + } 106 + 107 + retry := req.Clone(req.Context()) 108 + if req.GetBody != nil { 109 + retry.Body, err = req.GetBody() 110 + if err != nil { 111 + return nil, fmt.Errorf("API request retry GetBody failed: %w", err) 112 + } 113 + } 114 + 115 + accessToken, _ = a.GetTokens() 116 + 117 + retry.Header.Set("Authorization", "Bearer "+accessToken) 118 + retryResp, err := c.Do(retry) 119 + if err != nil { 120 + return nil, err 121 + } 122 + // NOTE: could handle auth failure as special error type here 123 + return retryResp, err 124 + } 125 + 126 + // Returns current access and refresh tokens (take a read-lock on session data) 127 + func (a *PasswordAuth) GetTokens() (string, string) { 128 + a.lk.RLock() 129 + defer a.lk.RUnlock() 130 + return a.Session.AccessToken, a.Session.RefreshToken 131 + } 132 + 133 + // Refreshes auth tokens (takes a write-lock on session data). 134 + // 135 + // `priorRefreshToken` argument is used to check if a concurrent refresh already took place. 136 + func (a *PasswordAuth) Refresh(ctx context.Context, c *http.Client, priorRefreshToken string) error { 137 + 138 + a.lk.Lock() 139 + defer a.lk.Unlock() 140 + 141 + // basic concurrency check: if refresh token already changed, can bail here (releasing lock) 142 + if priorRefreshToken != "" && priorRefreshToken != a.Session.RefreshToken { 143 + return nil 144 + } 145 + 146 + u := a.Session.Host + "/xrpc/com.atproto.server.refreshSession" 147 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) 148 + if err != nil { 149 + return err 150 + } 151 + // NOTE: could try to pull User-Agent from a request and pass that through to here 152 + req.Header.Set("User-Agent", "indigo-sdk") 153 + 154 + // NOTE: using refresh token here, not access token 155 + req.Header.Set("Authorization", "Bearer "+a.Session.RefreshToken) 156 + 157 + resp, err := c.Do(req) 158 + if err != nil { 159 + return err 160 + } 161 + defer resp.Body.Close() 162 + 163 + if !(resp.StatusCode >= 200 && resp.StatusCode < 300) { 164 + var eb ErrorBody 165 + if err := json.NewDecoder(resp.Body).Decode(&eb); err != nil { 166 + return &APIError{StatusCode: resp.StatusCode} 167 + } 168 + // TODO: indicate in the error that it was from refresh process, not original request? 169 + return eb.APIError(resp.StatusCode) 170 + } 171 + 172 + var out refreshSessionResponse 173 + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { 174 + return err 175 + } 176 + 177 + a.Session.AccessToken = out.AccessJwt 178 + a.Session.RefreshToken = out.RefreshJwt 179 + 180 + if a.RefreshCallback != nil { 181 + snapshot := a.Session.Clone() 182 + a.RefreshCallback(ctx, snapshot) 183 + } 184 + 185 + return nil 186 + } 187 + 188 + func (a *PasswordAuth) Logout(ctx context.Context, c *http.Client) error { 189 + _, refreshToken := a.GetTokens() 190 + 191 + u := a.Session.Host + "/xrpc/com.atproto.server.deleteSession" 192 + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, nil) 193 + if err != nil { 194 + return err 195 + } 196 + // NOTE: could try to pull User-Agent from a request and pass that through to here 197 + req.Header.Set("User-Agent", "indigo-sdk") 198 + 199 + // NOTE: using refresh token here, not access token 200 + req.Header.Set("Authorization", "Bearer "+refreshToken) 201 + 202 + resp, err := c.Do(req) 203 + if err != nil { 204 + return err 205 + } 206 + defer resp.Body.Close() 207 + 208 + if !(resp.StatusCode >= 200 && resp.StatusCode < 300) { 209 + var eb ErrorBody 210 + if err := json.NewDecoder(resp.Body).Decode(&eb); err != nil { 211 + return &APIError{StatusCode: resp.StatusCode} 212 + } 213 + return eb.APIError(resp.StatusCode) 214 + } 215 + return nil 216 + } 217 + 218 + // Creates a new [APIClient] with [PasswordAuth] for the provided user. The provided identity directory is used to resolve the PDS host for the account. 219 + // 220 + // `authToken` is optional; is used when multi-factor authentication is enabled for the account. 221 + // 222 + // `cb` is an optional callback which will be called with updated session data after any token refresh. 223 + func LoginWithPassword(ctx context.Context, dir identity.Directory, username syntax.AtIdentifier, password, authToken string, cb RefreshCallback) (*APIClient, error) { 224 + 225 + ident, err := dir.Lookup(ctx, username) 226 + if err != nil { 227 + return nil, err 228 + } 229 + 230 + host := ident.PDSEndpoint() 231 + if host == "" { 232 + return nil, fmt.Errorf("account does not have PDS registered") 233 + } 234 + 235 + c, err := LoginWithPasswordHost(ctx, host, ident.DID.String(), password, authToken, cb) 236 + if err != nil { 237 + return nil, err 238 + } 239 + 240 + if c.AccountDID == nil || *c.AccountDID != ident.DID { 241 + return nil, fmt.Errorf("returned session DID not requested account: %s", c.AccountDID) 242 + } 243 + 244 + return c, nil 245 + } 246 + 247 + // Creates a new [APIClient] with [PasswordAuth], based on a login to the provided host. Note that with some PDS implementations, 'username' could be an email address. This login method also works in situations where an account's network identity does not resolve to this specific host. 248 + // 249 + // `authToken` is optional; is used when multi-factor authentication is enabled for the account. 250 + // 251 + // `cb` is an optional callback which will be called with updated session data after any token refresh. 252 + func LoginWithPasswordHost(ctx context.Context, host, username, password, authToken string, cb RefreshCallback) (*APIClient, error) { 253 + 254 + c := NewAPIClient(host) 255 + reqBody := createSessionRequest{ 256 + Identifier: username, 257 + Password: password, 258 + } 259 + if authToken != "" { 260 + reqBody.AuthFactorToken = &authToken 261 + } 262 + 263 + var out createSessionResponse 264 + if err := c.Post(ctx, syntax.NSID("com.atproto.server.createSession"), &reqBody, &out); err != nil { 265 + return nil, err 266 + } 267 + 268 + if out.Active != nil && *out.Active == false { 269 + return nil, fmt.Errorf("account is disabled: %v", out.Status) 270 + } 271 + 272 + did, err := syntax.ParseDID(out.Did) 273 + if err != nil { 274 + return nil, err 275 + } 276 + 277 + ra := PasswordAuth{ 278 + Session: PasswordSessionData{ 279 + AccessToken: out.AccessJwt, 280 + RefreshToken: out.RefreshJwt, 281 + AccountDID: did, 282 + Host: c.Host, 283 + }, 284 + RefreshCallback: cb, 285 + } 286 + c.Auth = &ra 287 + c.AccountDID = &did 288 + return c, nil 289 + } 290 + 291 + // Creates an [APIClient] using [PasswordAuth], based on existing session data. 292 + // 293 + // `cb` is an optional callback which will be called with updated session data after any token refresh. 294 + func ResumePasswordSession(data PasswordSessionData, cb RefreshCallback) *APIClient { 295 + c := NewAPIClient(data.Host) 296 + ra := PasswordAuth{ 297 + Session: data, 298 + RefreshCallback: cb, 299 + } 300 + c.Auth = &ra 301 + c.AccountDID = &data.AccountDID 302 + return c 303 + }
+244
atproto/client/password_auth_test.go
··· 1 + package client 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "io" 9 + "net/http" 10 + "net/http/httptest" 11 + "os" 12 + "strings" 13 + "testing" 14 + 15 + "github.com/bluesky-social/indigo/atproto/identity" 16 + "github.com/bluesky-social/indigo/atproto/syntax" 17 + 18 + "github.com/stretchr/testify/assert" 19 + "github.com/stretchr/testify/require" 20 + ) 21 + 22 + func pwHandler(w http.ResponseWriter, r *http.Request) { 23 + switch r.URL.Path { 24 + case "/xrpc/com.atproto.server.refreshSession": 25 + //fmt.Println("refreshSession handler...") 26 + hdr := r.Header.Get("Authorization") 27 + if hdr != "Bearer refresh1" { 28 + fmt.Printf("refreshSession header: %s\n", hdr) 29 + w.Header().Set("WWW-Authenticate", `Bearer`) 30 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 31 + return 32 + } 33 + w.Header().Set("Content-Type", "application/json") 34 + json.NewEncoder(w).Encode(map[string]string{ 35 + "did": "did:web:account.example.com", 36 + "accessJwt": "access2", 37 + "refreshJwt": "refresh2", 38 + }) 39 + return 40 + case "/xrpc/com.atproto.server.deleteSession": 41 + //fmt.Println("deleteSession handler...") 42 + hdr := r.Header.Get("Authorization") 43 + if hdr != "Bearer refresh1" { 44 + fmt.Printf("refreshSession header: %s\n", hdr) 45 + w.Header().Set("WWW-Authenticate", `Bearer`) 46 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 47 + return 48 + } 49 + w.Header().Set("Content-Type", "application/json") 50 + return 51 + case "/xrpc/com.atproto.server.createSession": 52 + if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { 53 + fmt.Println("createSession Content-Type") 54 + http.Error(w, "Bad Request", http.StatusBadRequest) 55 + return 56 + } 57 + var body map[string]string 58 + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { 59 + fmt.Println("createSession JSON") 60 + http.Error(w, "Bad Request", http.StatusBadRequest) 61 + return 62 + } 63 + if body["identifier"] != "did:web:account.example.com" || body["password"] != "password1" { 64 + fmt.Println("createSession wrong password") 65 + http.Error(w, "Bad Request", http.StatusUnauthorized) 66 + return 67 + } 68 + 69 + w.Header().Set("Content-Type", "application/json") 70 + json.NewEncoder(w).Encode(map[string]string{ 71 + "did": body["identifier"], 72 + "accessJwt": "access1", 73 + "refreshJwt": "refresh1", 74 + }) 75 + return 76 + case "/xrpc/com.example.get", "/xrpc/com.example.post": 77 + hdr := r.Header.Get("Authorization") 78 + if hdr == "Bearer access1" || hdr == "Bearer access2" { 79 + w.Header().Set("Content-Type", "application/json") 80 + fmt.Fprintln(w, "{\"status\":\"success\"}") 81 + return 82 + } else { 83 + fmt.Printf("get header: %s\n", hdr) 84 + w.Header().Set("WWW-Authenticate", `Bearer`) 85 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 86 + return 87 + } 88 + case "/xrpc/com.example.expire": 89 + hdr := r.Header.Get("Authorization") 90 + if hdr == "Bearer access1" { 91 + //fmt.Println("forcing refresh...") 92 + w.Header().Set("Content-Type", "application/json") 93 + w.WriteHeader(400) 94 + fmt.Fprintln(w, "{\"error\":\"ExpiredToken\"}") 95 + return 96 + } else if hdr == "Bearer access2" { 97 + w.Header().Set("Content-Type", "application/json") 98 + fmt.Fprintln(w, "{\"status\":\"success\"}") 99 + return 100 + } else { 101 + fmt.Printf("expire header: %s\n", hdr) 102 + w.Header().Set("WWW-Authenticate", `Bearer`) 103 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 104 + return 105 + } 106 + default: 107 + http.NotFound(w, r) 108 + return 109 + } 110 + } 111 + 112 + func TestPasswordAuth(t *testing.T) { 113 + assert := assert.New(t) 114 + require := require.New(t) 115 + ctx := context.Background() 116 + 117 + srv := httptest.NewServer(http.HandlerFunc(pwHandler)) 118 + defer srv.Close() 119 + 120 + dir := identity.NewMockDirectory() 121 + dir.Insert(identity.Identity{ 122 + DID: "did:web:account.example.com", 123 + Handle: "user1.example.com", 124 + Services: map[string]identity.Service{ 125 + "atproto_pds": { 126 + Type: "AtprotoPersonalDataServer", 127 + URL: srv.URL, 128 + }, 129 + }, 130 + }) 131 + 132 + { 133 + // simple GET requests, with token expire/retry 134 + c, err := LoginWithPassword(ctx, &dir, syntax.Handle("user1.example.com").AtIdentifier(), "password1", "", nil) 135 + require.NoError(err) 136 + err = c.Get(ctx, syntax.NSID("com.example.get"), nil, nil) 137 + assert.NoError(err) 138 + err = c.Get(ctx, syntax.NSID("com.example.expire"), nil, nil) 139 + assert.NoError(err) 140 + } 141 + 142 + { 143 + // test resume session, and session data callback mechanism 144 + ch := make(chan string, 10) 145 + cb := func(ctx context.Context, data PasswordSessionData) { 146 + assert.Equal("refresh2", data.RefreshToken) 147 + ch <- "refreshed" 148 + } 149 + c := ResumePasswordSession(PasswordSessionData{ 150 + AccessToken: "access1", 151 + RefreshToken: "refresh1", 152 + AccountDID: syntax.DID("did:web:account.example.com"), 153 + Host: srv.URL, 154 + }, cb) 155 + 156 + err := c.Get(ctx, syntax.NSID("com.example.get"), nil, nil) 157 + assert.NoError(err) 158 + err = c.Get(ctx, syntax.NSID("com.example.expire"), nil, nil) 159 + assert.NoError(err) 160 + 161 + select { 162 + case msg := <-ch: 163 + assert.Equal("refreshed", msg) 164 + } 165 + } 166 + 167 + { 168 + // logout 169 + c, err := LoginWithPassword(ctx, &dir, syntax.Handle("user1.example.com").AtIdentifier(), "password1", "", nil) 170 + require.NoError(err) 171 + 172 + passAuth, ok := c.Auth.(*PasswordAuth) 173 + require.True(ok) 174 + err = passAuth.Logout(ctx, c.Client) 175 + assert.NoError(err) 176 + } 177 + 178 + { 179 + // simple POST request, with token expire/retry 180 + c, err := LoginWithPassword(ctx, &dir, syntax.Handle("user1.example.com").AtIdentifier(), "password1", "", nil) 181 + require.NoError(err) 182 + body := map[string]any{ 183 + "a": 123, 184 + "b": "hello", 185 + } 186 + var out json.RawMessage 187 + err = c.Post(ctx, syntax.NSID("com.example.post"), body, &out) 188 + assert.NoError(err) 189 + err = c.Post(ctx, syntax.NSID("com.example.expire"), body, &out) 190 + assert.NoError(err) 191 + } 192 + 193 + { 194 + // POST with bytes.Buffer body 195 + c, err := LoginWithPassword(ctx, &dir, syntax.Handle("user1.example.com").AtIdentifier(), "password1", "", nil) 196 + require.NoError(err) 197 + body := bytes.NewBufferString("some text") 198 + req := NewAPIRequest(MethodProcedure, syntax.NSID("com.example.expire"), body) 199 + req.Headers.Set("Content-Type", "text/plain") 200 + resp, err := c.Do(ctx, req) 201 + require.NoError(err) 202 + assert.Equal(200, resp.StatusCode) 203 + } 204 + 205 + { 206 + // POST with file on disk (can seek and retry) 207 + c, err := LoginWithPassword(ctx, &dir, syntax.Handle("user1.example.com").AtIdentifier(), "password1", "", nil) 208 + require.NoError(err) 209 + f, err := os.Open("testdata/body.json") 210 + require.NoError(err) 211 + req := NewAPIRequest(MethodProcedure, syntax.NSID("com.example.expire"), f) 212 + req.Headers.Set("Content-Type", "application/json") 213 + resp, err := c.Do(ctx, req) 214 + require.NoError(err) 215 + assert.Equal(200, resp.StatusCode) 216 + } 217 + 218 + { 219 + // POST with pipe reader (can *not* retry) 220 + c, err := LoginWithPassword(ctx, &dir, syntax.Handle("user1.example.com").AtIdentifier(), "password1", "", nil) 221 + require.NoError(err) 222 + r1, w1 := io.Pipe() 223 + go func() { 224 + fmt.Fprintf(w1, "some data") 225 + w1.Close() 226 + }() 227 + req1 := NewAPIRequest(MethodProcedure, syntax.NSID("com.example.post"), r1) 228 + req1.Headers.Set("Content-Type", "text/plain") 229 + resp, err := c.Do(ctx, req1) 230 + require.NoError(err) 231 + assert.Equal(200, resp.StatusCode) 232 + 233 + // expect this to fail (can't re-read from Pipe) 234 + r2, w2 := io.Pipe() 235 + go func() { 236 + fmt.Fprintf(w2, "some data") 237 + w2.Close() 238 + }() 239 + req2 := NewAPIRequest(MethodProcedure, syntax.NSID("com.example.expire"), r2) 240 + req2.Headers.Set("Content-Type", "text/plain") 241 + _, err = c.Do(ctx, req2) 242 + assert.Error(err) 243 + } 244 + }
+4
atproto/client/testdata/body.json
··· 1 + { 2 + "a": 123, 3 + "b": "hello" 4 + }