atproto_client 0.1.2 → 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: cbbb4053b1b25b1d1d3d37a6ae736570dd8d1bd097d69fde6d7868015fa45ac1
4
- data.tar.gz: 0a8cd5beac57a16b965aa7d5487a4896887d820ed012e7526091e28054111c1a
3
+ metadata.gz: 077b45fcdc42e913ff2054139af44943dfee953afc0eb4f0434af318ed217af4
4
+ data.tar.gz: a2e99843e274934e303d29b1dc50c58c639da2b81d430e0eda649160788ccdfe
5
5
  SHA512:
6
- metadata.gz: eac347a2e5d30aa247d1ae27c16aa5df76ee581945c0af5c3cb1fe31575332687b662a9105470ed5634c8912b041b83ed36f0fd5ca6248d44fde387918c0ba87
7
- data.tar.gz: 1711570172c8f61c617784c886386b6f2afe7b71c4435e1e353bab7314e1ae3651c64a661526f27915bfa9494e52c897e913c2bf804b8d01bb826d06280da006
6
+ metadata.gz: a700d9f9340ede4ecd2c61c501d2e4731919bea4e02cfeb2c9102bbe502e6d43538387ca3a2f867a06e51ea2daa9b935399b94b6d017a8a72102bcee4822debd
7
+ data.tar.gz: 5570db2d3df418bd83f30ca6c0ac7047865925d99758024db6027e681199f4389b4a28db8ac96e6ab07455ed3e596dd2311ba01ad5c32ed5b584566ce7d2158a
data/README.md CHANGED
@@ -1,7 +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.
3
+ Ruby client for the AT Protocol, with support for oauth/dpop authentication.
5
4
 
6
5
  ## Installation
7
6
 
@@ -14,32 +13,53 @@ gem 'atproto_client'
14
13
  ## Usage
15
14
 
16
15
  ```ruby
17
- # Configure the client (optional)
18
- AtProto.configure do |config|
19
- config.base_url = "https://bsky.social" # default
20
- end
21
16
 
22
- # Initialize a client
23
- client = AtProto::Client.new(access_token, refresh_token)
17
+ # Initialize with your private key and existing access token
18
+ client = AtProto::Client.new(private_key:, access_token:)
24
19
 
25
- # Should be able to fetch collections
26
- client.make_api_request(
20
+ # Then request
21
+ client.request(
27
22
  :get,
28
- "#{PDS_URL}/xrpc/#{lexicon}",
29
- 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"}
30
25
  )
31
26
 
32
- # Also gives a handful DPOP handler for any request (here an oauth example)
33
- dpop_handler = AtProto::DpopHandler.new(options.dpop_private_key)
34
- response = @dpop_handler.make_request(
35
- token_url,
27
+ # Body and params are optionals
28
+ # Body will be stringified to json if it's a hash
29
+ client.request(
36
30
  :post,
37
- headers: { "Content-Type" => "application/json", "Accept" => "application/json" },
38
- body: token_params
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
+ }
39
40
  )
40
41
 
41
- ```
42
+ # Can make requests with headers and custom body type
43
+ client.request(
44
+ :post,
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
+ }
51
+ )
42
52
 
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: )
56
+
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)
61
+
62
+ ```
43
63
  ## Development
44
64
 
45
65
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
@@ -1,57 +1,168 @@
1
1
  module AtProto
2
+ # The Client class handles authenticated HTTP requests to the AT Protocol services
3
+ # with DPoP token support and token request capabilities.
4
+ #
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
+ # @attr_reader [DpopHandler] dpop_handler The handler for DPoP token operations
2
8
  class Client
3
- attr_reader :access_token, :refresh_token, :dpop_handler
9
+ attr_accessor :access_token, :dpop_handler
4
10
 
5
- def initialize(access_token, refresh_token, dpop_handler = nil)
11
+ # Initializes a new AT Protocol client
12
+ #
13
+ # @param private_key [OpenSSL::PKey::EC] The EC private key used for DPoP token signing (required)
14
+ # @param access_token [String, nil] Optional access token for authentication
15
+ #
16
+ # @raise [ArgumentError] If private_key is not provided or not an OpenSSL::PKey::EC instance
17
+ def initialize(private_key:, access_token: nil)
18
+ @private_key = private_key
6
19
  @access_token = access_token
7
- @refresh_token = refresh_token
8
- @dpop_handler = dpop_handler || DpopHandler.new
9
- @token_mutex = Mutex.new
10
- end
11
-
12
- def make_api_request(method, url, params: {}, body: nil)
13
- retries = 0
14
- begin
15
- uri = URI(url)
16
- uri.query = URI.encode_www_form(params) if params.any?
17
- @dpop_handler.make_request(
18
- uri.to_s,
19
- method,
20
- headers: { 'Authorization' => "DPoP #{@access_token}" },
21
- body: body
20
+ @dpop_handler = DpopHandler.new(private_key, access_token)
21
+ end
22
+
23
+ # Sets a new private key for DPoP token signing
24
+ #
25
+ # @param private_key [OpenSSL::PKey::EC] The EC private key to use for signing DPoP tokens (required)
26
+ # @raise [ArgumentError] If private_key is not an OpenSSL::PKey::EC instance
27
+ def private_key=(private_key)
28
+ @dpop_handler = @dpop_handler.new(private_key, @access_token)
29
+ end
30
+
31
+ # Makes an authenticated HTTP request
32
+ #
33
+ # @param method [Symbol] The HTTP method to use (:get, :post, etc.)
34
+ # @param url [String] The URL to send the request to
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
37
+ #
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
54
+ #
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
22
80
  )
23
- rescue TokenExpiredError => e
24
- raise e unless retries.zero? && @refresh_token
81
+ )
82
+ @access_token = response['access_token']
83
+ response
84
+ end
25
85
 
26
- retries += 1
27
- refresh_access_token!
28
- retry
29
- end
86
+ # Refreshes the access token using a refresh token
87
+ #
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
93
+ #
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
111
+ )
112
+ )
113
+ @access_token = response['access_token']
114
+ @dpop_handler.access_token = @access_token
115
+ response
30
116
  end
31
117
 
32
118
  private
33
119
 
34
- def refresh_access_token!
35
- @token_mutex.synchronize do
36
- response = @dpop_handler.make_request(
37
- "#{base_url}/xrpc/com.atproto.server.refreshSession",
38
- :post,
39
- headers: {},
40
- body: { refresh_token: @refresh_token }
41
- )
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
42
129
 
43
- unless response.is_a?(Net::HTTPSuccess)
44
- raise RefreshTokenError, "Failed to refresh token: #{response.code} - #{response.body}"
45
- end
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
46
137
 
47
- data = JSON.parse(response.body)
48
- @access_token = data['access_token']
49
- @refresh_token = data['refresh_token']
50
- end
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
+ }
51
144
  end
52
145
 
53
- def base_url
54
- AtProto.configuration.base_url
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
+ }
155
+
156
+ JWT.encode(
157
+ jwt_payload,
158
+ @private_key,
159
+ 'ES256',
160
+ {
161
+ typ: 'jwt',
162
+ alg: 'ES256',
163
+ kid: jwk[:kid]
164
+ }
165
+ )
55
166
  end
56
167
  end
57
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
@@ -63,7 +65,6 @@ module AtProto
63
65
  OpenSSL::PKey::EC.generate('prime256v1').tap(&:check_key)
64
66
  end
65
67
 
66
- # Creates a DPoP token with the specified parameters, encoded by jwk
67
68
  def create_dpop_token(http_method, target_uri, nonce = nil)
68
69
  jwk = JWT::JWK.new(@private_key).export
69
70
  payload = {
@@ -73,19 +74,15 @@ module AtProto
73
74
  iat: Time.now.to_i,
74
75
  exp: Time.now.to_i + 120
75
76
  }
76
-
77
- # Ajout du hachage du token d'accès si fourni
78
- if @access_token
79
- token_str = @access_token.to_s
80
- sha256 = OpenSSL::Digest.new('SHA256')
81
- hash_bytes = sha256.digest(token_str)
82
- ath = Base64.urlsafe_encode64(hash_bytes, padding: false)
83
- payload[:ath] = ath
84
- end
85
-
77
+ payload[:ath] = generate_ath if @access_token
86
78
  payload[:nonce] = nonce if nonce
87
79
 
88
80
  JWT.encode(payload, @private_key, 'ES256', { typ: 'dpop+jwt', alg: 'ES256', jwk: jwk })
89
81
  end
82
+
83
+ def generate_ath
84
+ hash_bytes = OpenSSL::Digest.new('SHA256').digest(@access_token)
85
+ Base64.urlsafe_encode64(hash_bytes, padding: false)
86
+ end
90
87
  end
91
88
  end
@@ -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.2'
2
+ VERSION = '0.1.4'
3
3
  end
@@ -19,7 +19,6 @@ module AtProto
19
19
  class APIError < Error; end
20
20
  end
21
21
 
22
- require 'atproto_client/configuration'
23
22
  require 'atproto_client/client'
24
23
  require 'atproto_client/dpop_handler'
25
24
  require 'atproto_client/request'
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.2
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-24 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
@@ -146,7 +146,6 @@ files:
146
146
  - README.md
147
147
  - lib/atproto_client.rb
148
148
  - lib/atproto_client/client.rb
149
- - lib/atproto_client/configuration.rb
150
149
  - lib/atproto_client/dpop_handler.rb
151
150
  - lib/atproto_client/request.rb
152
151
  - lib/atproto_client/version.rb
@@ -1,19 +0,0 @@
1
- module AtProto
2
- class Configuration
3
- attr_accessor :base_url
4
-
5
- def initialize
6
- @base_url = 'https://bsky.social'
7
- end
8
- end
9
-
10
- class << self
11
- def configuration
12
- @configuration ||= Configuration.new
13
- end
14
-
15
- def configure
16
- yield(configuration)
17
- end
18
- end
19
- end