atproto_client 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 89742c30ed6ad23210c8e5eaecab1cabd26525419fe040bf1ca1f3a70ea29df7
4
- data.tar.gz: 3b3854a97f5b74fb32e8e0cf98ad2566ab1ae652e64b543ff764286e0f0a8c8f
3
+ metadata.gz: 077b45fcdc42e913ff2054139af44943dfee953afc0eb4f0434af318ed217af4
4
+ data.tar.gz: a2e99843e274934e303d29b1dc50c58c639da2b81d430e0eda649160788ccdfe
5
5
  SHA512:
6
- metadata.gz: 88e12f5de1cb3e7075b42f07350ccccc57414e9e1293b76519b3e29a1682e166a1ec6708606de549ed22c6610add5c8176c40e776e08c1b2e64fb56f5861656a
7
- data.tar.gz: d0a350fae5b3eded1590da41a072bf5e9b75641339b1a9e15ef8f58c828b974d67c0d88e463df6da2ea3a390340245e7de4f8f12945ecacb896133aa82d13233
6
+ metadata.gz: a700d9f9340ede4ecd2c61c501d2e4731919bea4e02cfeb2c9102bbe502e6d43538387ca3a2f867a06e51ea2daa9b935399b94b6d017a8a72102bcee4822debd
7
+ data.tar.gz: 5570db2d3df418bd83f30ca6c0ac7047865925d99758024db6027e681199f4389b4a28db8ac96e6ab07455ed3e596dd2311ba01ad5c32ed5b584566ce7d2158a
data/README.md CHANGED
@@ -1,13 +1,6 @@
1
1
  # ATProto Client
2
2
 
3
- Ruby client for the AT Protocol, with support for oauth/dpop authentication. It has been built and tested for bluesky but it should be agnostic of PDS. An omniauth strategy using this layer should appear soon.
4
- The work is in progress but it should allready work and I'd be happy to have feedbacks.
5
-
6
- ### TODO
7
- - [ ] Reject response without DPoP-Nonce header (to respect the spec)
8
- - [ ] Try to reuse nonce better (currently we double each request to refresh it from the server each time)
9
- - [ ] Give a better API (it changes at a hight rate currently)
10
-
3
+ Ruby client for the AT Protocol, with support for oauth/dpop authentication.
11
4
 
12
5
  ## Installation
13
6
 
@@ -21,34 +14,52 @@ gem 'atproto_client'
21
14
 
22
15
  ```ruby
23
16
 
24
- # Initialize a client
25
- client = AtProto::Client.new(
26
- access_token: access_token,
27
- refresh_token: refresh_token,
28
- private_key: private_key_used_for_access_token_creation,
29
- refresh_token_url: "https://the-token-server.com" # optional, defaults do https://bsky.social
30
- )
17
+ # Initialize with your private key and existing access token
18
+ client = AtProto::Client.new(private_key:, access_token:)
31
19
 
32
- # Should be able to fetch collections
20
+ # Then request
33
21
  client.request(
34
22
  :get,
35
- "#{PDS_URL}/xrpc/#{lexicon}",
36
- params:{ repo: "did:therepodid", collection: "app.bsky.feed.post"}
23
+ "https://boletus.us-west.host.bsky.network/xrpc/app.bsky.feed.getPostThread",
24
+ params: { uri: "at://did:plc:sdy3olcdgcxvy3enfgsujz43/app.bsky.feed.post/3lbr6ey544s2k"}
25
+ )
26
+
27
+ # Body and params are optionals
28
+ # Body will be stringified to json if it's a hash
29
+ client.request(
30
+ :post,
31
+ "#{pds_endpoint}/xrpc/com.atproto.repo.createRecord",
32
+ body: {
33
+ repo: did,
34
+ collection: "app.bsky.feed.post",
35
+ record: {
36
+ text: "Posting from ruby",
37
+ createdAt: Time.now.iso8601,
38
+ }
39
+ }
37
40
  )
38
41
 
39
- # Also gives a handful DPOP handler for any request (here an omniauth example)
40
- dpop_handler = AtProto::DpopHandler.new(options.dpop_private_key)
41
- response = @dpop_handler.make_request(
42
- token_url,
42
+ # Can make requests with headers and custom body type
43
+ client.request(
43
44
  :post,
44
- headers: { "Content-Type" => "application/json", "Accept" => "application/json" },
45
- body: token_params
45
+ "#{pds_endpoint}/xrpc/com.atproto.repo.uploadBlob",
46
+ body: image_data,
47
+ headers: {
48
+ "Content-Type": content_type,
49
+ "Content-Length": content_length
50
+ }
46
51
  )
47
52
 
48
- # you can then use the access_token from the response in conjunction with the same private_key
53
+ # Refresh token when needed
54
+ # Tokens are returned so they can be stored
55
+ client.refresh_token!(refresh_token:, jwk:, client_id:, site:, endpoint: )
49
56
 
50
- ```
57
+ # Get initial access_token
58
+ # (to be used in oauth flow -- see https://github.com/lasercatspro/omniauth-atproto)
59
+ client = AtProto::Client.new(private_key: key)
60
+ client.get_token!(code:, jwk:, client_id:, site:, endpoint:, code_verifier)
51
61
 
62
+ ```
52
63
  ## Development
53
64
 
54
65
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
@@ -1,27 +1,23 @@
1
1
  module AtProto
2
2
  # The Client class handles authenticated HTTP requests to the AT Protocol services
3
- # with DPoP token support and automatic token refresh capabilities.
3
+ # with DPoP token support and token request capabilities.
4
4
  #
5
- # @attr_reader [String] access_token The current access token for authentication
6
- # @attr_reader [String] refresh_token The current refresh token for renewing access
5
+ # @attr_accessor [String] access_token The current access token for authentication
6
+ # @attr_accessor [String] private_key The private key corresponding to the public jwk of the app
7
7
  # @attr_reader [DpopHandler] dpop_handler The handler for DPoP token operations
8
8
  class Client
9
- attr_reader :access_token, :refresh_token, :dpop_handler
9
+ attr_accessor :access_token, :dpop_handler
10
10
 
11
11
  # Initializes a new AT Protocol client
12
12
  #
13
- # @param access_token [String] The initial access token for authentication
14
- # @param refresh_token [String] The refresh token for renewing access tokens
15
13
  # @param private_key [OpenSSL::PKey::EC] The EC private key used for DPoP token signing (required)
16
- # @param refresh_token_url [String] The base URL for token refresh requests
14
+ # @param access_token [String, nil] Optional access token for authentication
17
15
  #
18
16
  # @raise [ArgumentError] If private_key is not provided or not an OpenSSL::PKey::EC instance
19
- def initialize(access_token:, refresh_token:, private_key:, refresh_token_url: 'https://bsky.social')
17
+ def initialize(private_key:, access_token: nil)
18
+ @private_key = private_key
20
19
  @access_token = access_token
21
- @refresh_token = refresh_token
22
- @refresh_token_url = refresh_token_url
23
20
  @dpop_handler = DpopHandler.new(private_key, access_token)
24
- @token_mutex = Mutex.new
25
21
  end
26
22
 
27
23
  # Sets a new private key for DPoP token signing
@@ -32,61 +28,141 @@ module AtProto
32
28
  @dpop_handler = @dpop_handler.new(private_key, @access_token)
33
29
  end
34
30
 
35
- # Makes an authenticated HTTP request with automatic token refresh
31
+ # Makes an authenticated HTTP request
36
32
  #
37
33
  # @param method [Symbol] The HTTP method to use (:get, :post, etc.)
38
34
  # @param url [String] The URL to send the request to
39
- # @param params [Hash] Optional query parameters
40
- # @param body [Hash, nil] Optional request body
35
+ # @param params [Hash] Optional query parameters to be added to the URL
36
+ # @param body [Hash, nil] Optional request body for POST/PUT requests
41
37
  #
42
- # @return [Net::HTTPResponse] The HTTP response
38
+ # @return [Hash] The parsed JSON response
39
+ # @raise [TokenExpiredError] When the access token has expired
40
+ # @raise [AuthError] When forbidden by the server for other reasons
41
+ # @raise [APIError] On other errors from the server
42
+ def request(method, url, params: {}, body: nil, headers: {})
43
+ uri = URI(url)
44
+ uri.query = URI.encode_www_form(params) if params.any?
45
+ @dpop_handler.make_request(
46
+ uri.to_s,
47
+ method,
48
+ headers: { 'Authorization' => "DPoP #{@access_token}" }.merge(headers),
49
+ body: body
50
+ )
51
+ end
52
+
53
+ # Gets a new access token using an authorization code
43
54
  #
44
- # @raise [TokenExpiredError] When token refresh fails
45
- # @raise [RefreshTokenError] When unable to refresh the access token
46
- def request(method, url, params: {}, body: nil)
47
- retries = 0
48
- begin
49
- uri = URI(url)
50
- uri.query = URI.encode_www_form(params) if params.any?
51
- @dpop_handler.make_request(
52
- uri.to_s,
53
- method,
54
- headers: { 'Authorization' => "DPoP #{@access_token}" },
55
- body: body
55
+ # @param code [String] The authorization code
56
+ # @param jwk [Hash] The JWK for signing
57
+ # @param client_id [String] The client ID
58
+ # @param site [String] The token audience
59
+ # @param endpoint [String] The token endpoint URL
60
+ # @param redirect_uri [String] The application's oauth callback url
61
+ #
62
+ # @return [Hash] The token response
63
+ # @raise [AuthError] When forbidden by the server
64
+ # @raise [APIError] On other errors from the server
65
+ def get_token!(code:, jwk:, client_id:, site:, endpoint:, redirect_uri:, code_verifier:)
66
+ response = @dpop_handler.make_request(
67
+ endpoint,
68
+ :post,
69
+ headers: {
70
+ 'Content-Type' => 'application/json',
71
+ 'Accept' => 'application/json'
72
+ },
73
+ body: token_params(
74
+ code: code,
75
+ jwk: jwk,
76
+ client_id: client_id,
77
+ site: site,
78
+ redirect_uri: redirect_uri,
79
+ code_verifier: code_verifier
56
80
  )
57
- rescue TokenExpiredError => e
58
- raise e unless retries.zero? && @refresh_token
59
-
60
- retries += 1
61
- refresh_access_token!
62
- retry
63
- end
81
+ )
82
+ @access_token = response['access_token']
83
+ response
64
84
  end
65
85
 
66
- private
67
-
68
- # Refreshes the access token using the refresh token
86
+ # Refreshes the access token using a refresh token
69
87
  #
70
- # @private
88
+ # @param refresh_token [String] The refresh token
89
+ # @param jwk [Hash] The JWK for signing
90
+ # @param client_id [String] The client ID
91
+ # @param site [String] The token audience
92
+ # @param endpoint [String] The token endpoint URL
71
93
  #
72
- # @raise [RefreshTokenError] When the token refresh request fails
73
- def refresh_access_token!
74
- @token_mutex.synchronize do
75
- response = @dpop_handler.make_request(
76
- "#{@refresh_token_url}/xrpc/com.atproto.server.refreshSession",
77
- :post,
78
- headers: {},
79
- body: { refresh_token: @refresh_token }
94
+ # @return [Hash] The token response
95
+ # @raise [AuthError] When forbidden by the server
96
+ # @raise [APIError] On other errors from the server
97
+ def refresh_token!(refresh_token:, jwk:, client_id:, site:, endpoint:)
98
+ @dpop_handler.access_token = nil
99
+ response = @dpop_handler.make_request(
100
+ endpoint,
101
+ :post,
102
+ headers: {
103
+ 'Content-Type' => 'application/json',
104
+ 'Accept' => 'application/json'
105
+ },
106
+ body: refresh_token_params(
107
+ refresh_token: refresh_token,
108
+ jwk: jwk,
109
+ client_id: client_id,
110
+ site: site
80
111
  )
112
+ )
113
+ @access_token = response['access_token']
114
+ @dpop_handler.access_token = @access_token
115
+ response
116
+ end
117
+
118
+ private
119
+
120
+ def token_params(code:, jwk:, client_id:, site:, redirect_uri:, code_verifier:)
121
+ {
122
+ grant_type: 'authorization_code',
123
+ redirect_uri: redirect_uri,
124
+ code: code,
125
+ code_verifier: code_verifier,
126
+ **base_token_params(jwk: jwk, client_id: client_id, site: site)
127
+ }
128
+ end
129
+
130
+ def refresh_token_params(refresh_token:, jwk:, client_id:, site:)
131
+ {
132
+ grant_type: 'refresh_token',
133
+ refresh_token: refresh_token,
134
+ **base_token_params(jwk: jwk, client_id: client_id, site: site)
135
+ }
136
+ end
137
+
138
+ def base_token_params(jwk:, client_id:, site:)
139
+ {
140
+ client_id: client_id,
141
+ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
142
+ client_assertion: generate_client_assertion(jwk: jwk, client_id: client_id, site: site)
143
+ }
144
+ end
81
145
 
82
- unless response.is_a?(Net::HTTPSuccess)
83
- raise RefreshTokenError, "Failed to refresh token: #{response.code} - #{response.body}"
84
- end
146
+ def generate_client_assertion(jwk:, client_id:, site:)
147
+ jwt_payload = {
148
+ iss: client_id,
149
+ sub: client_id,
150
+ aud: site,
151
+ jti: SecureRandom.uuid,
152
+ iat: Time.now.to_i,
153
+ exp: Time.now.to_i + 300
154
+ }
85
155
 
86
- data = JSON.parse(response.body)
87
- @access_token = data['access_token']
88
- @refresh_token = data['refresh_token']
89
- end
156
+ JWT.encode(
157
+ jwt_payload,
158
+ @private_key,
159
+ 'ES256',
160
+ {
161
+ typ: 'jwt',
162
+ alg: 'ES256',
163
+ kid: jwk[:kid]
164
+ }
165
+ )
90
166
  end
91
167
  end
92
168
  end
@@ -1,6 +1,8 @@
1
1
  module AtProto
2
2
  # Handler for DPoP (Demonstrating Proof-of-Possession) protocol implementation
3
3
  class DpopHandler
4
+ attr_accessor :private_key, :access_token
5
+
4
6
  # Initialize a new DPoP handler
5
7
  # @param private_key [OpenSSL::PKey::EC, nil] Optional private key for signing tokens
6
8
  # @param access_token [String] Optional access_token
@@ -45,7 +47,7 @@ module AtProto
45
47
  begin
46
48
  dpop_token = generate_token(method.to_s.upcase, uri.to_s)
47
49
  request = Request.new(method, uri, headers.merge('DPoP' => dpop_token))
48
- request.body = body.to_json if body
50
+ request.body = body.is_a?(Hash) ? body.to_json : body if body
49
51
  request.run
50
52
  rescue Net::HTTPClientException => e
51
53
  unless retried
@@ -37,9 +37,9 @@ module AtProto
37
37
  def run
38
38
  request_class = HTTP_METHODS[method]
39
39
  req = request_class.new(uri).tap do |request|
40
- headers.each { |k, v| request[k] = v }
41
40
  request['Content-Type'] = 'application/json'
42
41
  request['Accept'] = 'application/json'
42
+ headers.each { |k, v| request[k] = v }
43
43
  request.body = body
44
44
  end
45
45
  response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
@@ -62,9 +62,9 @@ module AtProto
62
62
  when 400..499
63
63
  body = JSON.parse(response.body)
64
64
  response.error! if body['error'] == 'use_dpop_nonce'
65
- raise TokenExpiredError if body['error'] == 'TokenExpiredError'
65
+ raise TokenExpiredError if body['error'] == 'invalid_token'
66
66
 
67
- raise AuthError, "Unauthorized: #{body['error']}"
67
+ raise AuthError, "Unauthorized: #{body.values_at('error', 'message').compact.join(' - ')}"
68
68
  when 200..299
69
69
  JSON.parse(response.body)
70
70
  else
@@ -1,3 +1,3 @@
1
1
  module AtProto
2
- VERSION = '0.1.3'
2
+ VERSION = '0.1.4'
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: atproto_client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - frabr
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-11-25 00:00:00.000000000 Z
11
+ date: 2024-12-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jwt