atproto_client 0.1.0

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