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 +7 -0
- data/README.md +52 -0
- data/lib/atproto_client/client.rb +57 -0
- data/lib/atproto_client/configuration.rb +19 -0
- data/lib/atproto_client/dpop_handler.rb +80 -0
- data/lib/atproto_client/request.rb +77 -0
- data/lib/atproto_client/version.rb +3 -0
- data/lib/atproto_client.rb +25 -0
- metadata +176 -0
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,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: []
|