atproto_client 0.1.3 → 0.1.4

Sign up to get free protection for your applications and to get access to all the features.
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