atproto_client 0.1.0

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e459f2369670aec62ba641789c28a0e06e8c28892c11c490237388a99602f8d3
4
+ data.tar.gz: 2795f534c54c0021a027699c6d58fa0f36b33680b80c060be0aff301d7fc3b73
5
+ SHA512:
6
+ metadata.gz: 92223d17916001ae87453c85de3f4cfb34877f77c11e5ff31991eca0a58b2a744bd21ab3381463e78b4c8a21db816b99887105d7651f9fde4091e25251eb7f5b
7
+ data.tar.gz: 6b2fd5552ccfe4239345052892d03af7a36ac26d47536eab9f79b40980e369d8799ed44c0276b6de8f68fcece79e81e782fd0b2710c39ee55bd23b783468d7b0
data/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # ATProto Client
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
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'atproto_client'
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ruby
16
+ # Configure the client (optional)
17
+ AtProto.configure do |config|
18
+ config.base_url = "https://bsky.social" # default
19
+ end
20
+
21
+ # Initialize a client
22
+ client = AtProto::Client.new(access_token, refresh_token)
23
+
24
+ # Should be able to fetch collections
25
+ client.make_api_request(
26
+ :get,
27
+ "#{PDS_URL}/xrpc/#{lexicon}",
28
+ params:{ repo: "did:therepodid", collection: "app.bsky.feed.post"}
29
+ )
30
+
31
+ # Also gives a handful DPOP handler for any request (here an oauth example)
32
+ dpop_handler = AtProto::DpopHandler.new(options.dpop_private_key)
33
+ response = @dpop_handler.make_request(
34
+ token_url,
35
+ :post,
36
+ headers: { "Content-Type" => "application/json", "Accept" => "application/json" },
37
+ body: token_params
38
+ )
39
+
40
+ ```
41
+
42
+ ## Development
43
+
44
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
45
+
46
+ ## Contributing
47
+
48
+ Bug reports and pull requests are welcome on GitHub at https://github.com/lasercats/atproto_client.
49
+
50
+ ## License
51
+
52
+ The gem is available as open source under the terms of the MIT License.
@@ -0,0 +1,57 @@
1
+ module AtProto
2
+ class Client
3
+ attr_reader :access_token, :refresh_token, :dpop_handler
4
+
5
+ def initialize(access_token, refresh_token, dpop_handler = nil)
6
+ @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' => "Bearer #{@access_token}" },
21
+ body: body
22
+ )
23
+ rescue TokenExpiredError => e
24
+ raise e unless retries.zero? && @refresh_token
25
+
26
+ retries += 1
27
+ refresh_access_token!
28
+ retry
29
+ end
30
+ end
31
+
32
+ private
33
+
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
+ )
42
+
43
+ unless response.is_a?(Net::HTTPSuccess)
44
+ raise RefreshTokenError, "Failed to refresh token: #{response.code} - #{response.body}"
45
+ end
46
+
47
+ data = JSON.parse(response.body)
48
+ @access_token = data['access_token']
49
+ @refresh_token = data['refresh_token']
50
+ end
51
+ end
52
+
53
+ def base_url
54
+ AtProto.configuration.base_url
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,19 @@
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
@@ -0,0 +1,80 @@
1
+ module AtProto
2
+ # Handler for DPoP (Demonstrating Proof-of-Possession) protocol implementation
3
+ class DpopHandler
4
+ # Initialize a new DPoP handler
5
+ # @param private_key [OpenSSL::PKey::EC, nil] Optional private key for signing tokens
6
+ def initialize(private_key = nil)
7
+ @private_key = private_key || generate_private_key
8
+ @current_nonce = nil
9
+ @nonce_mutex = Mutex.new
10
+ @token_mutex = Mutex.new
11
+ end
12
+
13
+ # Generates a DPoP token for a request
14
+ # @param http_method [String] The HTTP method of the request
15
+ # @param url [String] The target URL of the request
16
+ # @param nonce [String, nil] Optional nonce value
17
+ # @return [String] The generated DPoP token
18
+ def generate_token(http_method, url, nonce = @current_nonce)
19
+ @token_mutex.synchronize do
20
+ create_dpop_token(http_method, url, nonce)
21
+ end
22
+ end
23
+
24
+ # Updates the current nonce from response headers
25
+ # @param response [Net::HTTPResponse] Response containing dpop-nonce header
26
+ def update_nonce(response)
27
+ new_nonce = response.to_hash.dig('dpop-nonce', 0)
28
+ @nonce_mutex.synchronize do
29
+ @current_nonce = new_nonce if new_nonce
30
+ end
31
+ end
32
+
33
+ # Makes an HTTP request with DPoP handling,
34
+ # when no nonce is used for the first try, takes it from the response and retry
35
+ # @param uri [String] The target URI
36
+ # @param method [String] The HTTP method
37
+ # @param headers [Hash] Optional request headers
38
+ # @param body [Hash, nil] Optional request body
39
+ # @return [Net::HTTPResponse] The HTTP response
40
+ # @raise [APIError] If the request fails
41
+ def make_request(uri, method, headers: {}, body: nil)
42
+ retried = false
43
+ begin
44
+ dpop_token = generate_token(method.to_s.upcase, uri.to_s)
45
+ request = Request.new(method, uri, headers.merge('DPoP' => dpop_token))
46
+ request.body = body.to_json if body
47
+ request.run
48
+ rescue Net::HTTPClientException => e
49
+ unless retried
50
+ update_nonce(e.response)
51
+ retried = true
52
+ retry
53
+ end
54
+ raise APIError, "Request failed: #{e.response.code} - #{e.response.body}"
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def generate_private_key
61
+ OpenSSL::PKey::EC.generate('prime256v1').tap(&:check_key)
62
+ end
63
+
64
+ # Creates a DPoP token with the specified parameters, encoded by jwk
65
+ def create_dpop_token(http_method, target_uri, nonce = nil)
66
+ jwk = JWT::JWK.new(@private_key).export
67
+ payload = {
68
+ jti: SecureRandom.hex(16),
69
+ htm: http_method,
70
+ htu: target_uri,
71
+ iat: Time.now.to_i,
72
+ exp: Time.now.to_i + 120
73
+ }
74
+
75
+ payload[:nonce] = nonce if nonce
76
+
77
+ JWT.encode(payload, @private_key, 'ES256', { typ: 'dpop+jwt', alg: 'ES256', jwk: jwk })
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AtProto
4
+ # Handles HTTP requests for the AT Protocol client
5
+ # Throw the errors needed for the flow (Dpop and TokenInvalid)
6
+ class Request
7
+ # @return [URI] The URI for the request
8
+ # @return [Symbol] The HTTP method to use
9
+ # @return [Hash] Headers to be sent with the request
10
+ # @return [String, nil] The request body
11
+ attr_accessor :uri, :method, :headers, :body
12
+
13
+ # Creates a new Request instance
14
+ #
15
+ # @param method [Symbol] The HTTP method (:get, :post, :put, :delete)
16
+ # @param uri [String] The URI for the request
17
+ # @param headers [Hash] Optional headers to include
18
+ # @param body [String, nil] Optional request body
19
+ # @return [Request] A new Request instance
20
+ def initialize(method, uri, headers = {}, body = nil)
21
+ @uri = URI(uri)
22
+ @method = method
23
+ @headers = headers
24
+ @body = body
25
+ end
26
+
27
+ # Executes the HTTP request
28
+ #
29
+ # Makes the HTTP request with configured parameters and handles the response.
30
+ # Automatically sets Content-Type and Accept headers to application/json.
31
+ #
32
+ # @return [Hash] Parsed JSON response body
33
+ # @raise [Net::HTTPClientException] On bad request
34
+ # @raise [TokenExpiredError] When the authentication token has expired
35
+ # @raise [AuthError] When authentication fails
36
+ # @raise [APIError] When the API returns an unexpected error
37
+ def run
38
+ request_class = HTTP_METHODS[method]
39
+ req = request_class.new(uri).tap do |request|
40
+ headers.each { |k, v| request[k] = v }
41
+ request['Content-Type'] = 'application/json'
42
+ request['Accept'] = 'application/json'
43
+ request.body = body
44
+ end
45
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == 'https') do |http|
46
+ http.request(req)
47
+ end
48
+ handle_response(response)
49
+ end
50
+
51
+ private
52
+
53
+ HTTP_METHODS = {
54
+ get: Net::HTTP::Get,
55
+ post: Net::HTTP::Post,
56
+ put: Net::HTTP::Put,
57
+ delete: Net::HTTP::Delete
58
+ }.freeze
59
+
60
+ def handle_response(response)
61
+ case response.code.to_i
62
+ when 400
63
+ body = JSON.parse(response.body)
64
+ response.error! if body['error'] == 'use_dpop_nonce'
65
+ when 401
66
+ body = JSON.parse(response.body)
67
+ raise TokenExpiredError if body['error'] == 'TokenExpiredError'
68
+
69
+ raise AuthError, "Unauthorized: #{body['error']}"
70
+ when 200..299
71
+ JSON.parse(response.body)
72
+ else
73
+ raise APIError, "Request failed: #{response.code} - #{response.body}"
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,3 @@
1
+ module AtProto
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,25 @@
1
+ require 'atproto_client/version'
2
+ require 'jwt'
3
+ require 'openssl'
4
+ require 'securerandom'
5
+ require 'base64'
6
+ require 'json'
7
+ require 'net/http'
8
+ require 'uri'
9
+
10
+ module AtProto
11
+ class Error < StandardError; end
12
+
13
+ class AuthError < Error; end
14
+
15
+ class TokenExpiredError < AuthError; end
16
+
17
+ class RefreshTokenError < AuthError; end
18
+
19
+ class APIError < Error; end
20
+ end
21
+
22
+ require 'atproto_client/configuration'
23
+ require 'atproto_client/client'
24
+ require 'atproto_client/dpop_handler'
25
+ require 'atproto_client/request'
metadata ADDED
@@ -0,0 +1,176 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: atproto_client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - frabr
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2024-11-24 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: jwt
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.7'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.7'
27
+ - !ruby/object:Gem::Dependency
28
+ name: openssl
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: bundler
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '2.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '2.0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '13.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '13.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.12'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.12'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rubocop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.50'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.50'
97
+ - !ruby/object:Gem::Dependency
98
+ name: vcr
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '6.1'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '6.1'
111
+ - !ruby/object:Gem::Dependency
112
+ name: webmock
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '3.18'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '3.18'
125
+ - !ruby/object:Gem::Dependency
126
+ name: yard
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - "~>"
130
+ - !ruby/object:Gem::Version
131
+ version: '0.9'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - "~>"
137
+ - !ruby/object:Gem::Version
138
+ version: '0.9'
139
+ description: A Ruby client for the AT Protocol authenticated request
140
+ email:
141
+ - francois@lasercats.fr
142
+ executables: []
143
+ extensions: []
144
+ extra_rdoc_files: []
145
+ files:
146
+ - README.md
147
+ - lib/atproto_client.rb
148
+ - lib/atproto_client/client.rb
149
+ - lib/atproto_client/configuration.rb
150
+ - lib/atproto_client/dpop_handler.rb
151
+ - lib/atproto_client/request.rb
152
+ - lib/atproto_client/version.rb
153
+ homepage: https://github.com/lasercats/atproto_client
154
+ licenses:
155
+ - MIT
156
+ metadata: {}
157
+ post_install_message:
158
+ rdoc_options: []
159
+ require_paths:
160
+ - lib
161
+ required_ruby_version: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - ">="
164
+ - !ruby/object:Gem::Version
165
+ version: 2.7.0
166
+ required_rubygems_version: !ruby/object:Gem::Requirement
167
+ requirements:
168
+ - - ">="
169
+ - !ruby/object:Gem::Version
170
+ version: '0'
171
+ requirements: []
172
+ rubygems_version: 3.5.3
173
+ signing_key:
174
+ specification_version: 4
175
+ summary: AT Protocol client implementation for Ruby
176
+ test_files: []