atproto_client 0.1.2 → 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: 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