atproto_client 0.1.3 → 0.1.5
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 +4 -4
- data/LICENSE +21 -0
- data/README.md +37 -26
- data/lib/atproto_client/client.rb +129 -53
- data/lib/atproto_client/dpop_handler.rb +3 -1
- data/lib/atproto_client/request.rb +3 -3
- data/lib/atproto_client/version.rb +1 -1
- metadata +8 -10
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cb2bef8450100bcfca0e201638aa89faf296b837a7bde3acc70f3dc965cfd59e
|
|
4
|
+
data.tar.gz: 6b0007e69d14b2aba2360d41a0796541cc318a30a36a14a1c68b66cbdf3f4006
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7f0b8ea8946f42979bbb136deea41f012df0e2ee19aae2efde94eddc5c0e74fa5ee964e50d16455a992c9cbd2b0901bd0d226c0ed9023e3699a019556a41dd8d
|
|
7
|
+
data.tar.gz: 755325ecde64614566b61eb01c8b3b7d2f66023f66328951568a732d065cdb654ba2d56432e60ee0ffa750fe09cc86aa8aacc035fb92a6f963a1faf0fceeaca4
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 François Brault
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
# ATProto Client
|
|
2
2
|
|
|
3
|
-
Ruby client for the AT Protocol, with support for oauth/dpop authentication.
|
|
4
|
-
The work is in progress but it should allready work and I'd be happy to have feedbacks.
|
|
5
|
-
|
|
6
|
-
### TODO
|
|
7
|
-
- [ ] Reject response without DPoP-Nonce header (to respect the spec)
|
|
8
|
-
- [ ] Try to reuse nonce better (currently we double each request to refresh it from the server each time)
|
|
9
|
-
- [ ] Give a better API (it changes at a hight rate currently)
|
|
10
|
-
|
|
3
|
+
Ruby client for the AT Protocol, with support for oauth/dpop authentication.
|
|
11
4
|
|
|
12
5
|
## Installation
|
|
13
6
|
|
|
@@ -21,34 +14,52 @@ gem 'atproto_client'
|
|
|
21
14
|
|
|
22
15
|
```ruby
|
|
23
16
|
|
|
24
|
-
# Initialize
|
|
25
|
-
client = AtProto::Client.new(
|
|
26
|
-
access_token: access_token,
|
|
27
|
-
refresh_token: refresh_token,
|
|
28
|
-
private_key: private_key_used_for_access_token_creation,
|
|
29
|
-
refresh_token_url: "https://the-token-server.com" # optional, defaults do https://bsky.social
|
|
30
|
-
)
|
|
17
|
+
# Initialize with your private key and existing access token
|
|
18
|
+
client = AtProto::Client.new(private_key:, access_token:)
|
|
31
19
|
|
|
32
|
-
#
|
|
20
|
+
# Then request
|
|
33
21
|
client.request(
|
|
34
22
|
:get,
|
|
35
|
-
"
|
|
36
|
-
params:{
|
|
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"}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Body and params are optionals
|
|
28
|
+
# Body will be stringified to json if it's a hash
|
|
29
|
+
client.request(
|
|
30
|
+
:post,
|
|
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
|
+
}
|
|
37
40
|
)
|
|
38
41
|
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
response = @dpop_handler.make_request(
|
|
42
|
-
token_url,
|
|
42
|
+
# Can make requests with headers and custom body type
|
|
43
|
+
client.request(
|
|
43
44
|
:post,
|
|
44
|
-
|
|
45
|
-
body:
|
|
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
|
+
}
|
|
46
51
|
)
|
|
47
52
|
|
|
48
|
-
#
|
|
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: )
|
|
49
56
|
|
|
50
|
-
|
|
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)
|
|
51
61
|
|
|
62
|
+
```
|
|
52
63
|
## Development
|
|
53
64
|
|
|
54
65
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
|
|
@@ -1,27 +1,23 @@
|
|
|
1
1
|
module AtProto
|
|
2
2
|
# The Client class handles authenticated HTTP requests to the AT Protocol services
|
|
3
|
-
# with DPoP token support and
|
|
3
|
+
# with DPoP token support and token request capabilities.
|
|
4
4
|
#
|
|
5
|
-
# @
|
|
6
|
-
# @
|
|
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
7
|
# @attr_reader [DpopHandler] dpop_handler The handler for DPoP token operations
|
|
8
8
|
class Client
|
|
9
|
-
|
|
9
|
+
attr_accessor :access_token, :dpop_handler
|
|
10
10
|
|
|
11
11
|
# Initializes a new AT Protocol client
|
|
12
12
|
#
|
|
13
|
-
# @param access_token [String] The initial access token for authentication
|
|
14
|
-
# @param refresh_token [String] The refresh token for renewing access tokens
|
|
15
13
|
# @param private_key [OpenSSL::PKey::EC] The EC private key used for DPoP token signing (required)
|
|
16
|
-
# @param
|
|
14
|
+
# @param access_token [String, nil] Optional access token for authentication
|
|
17
15
|
#
|
|
18
16
|
# @raise [ArgumentError] If private_key is not provided or not an OpenSSL::PKey::EC instance
|
|
19
|
-
def initialize(
|
|
17
|
+
def initialize(private_key:, access_token: nil)
|
|
18
|
+
@private_key = private_key
|
|
20
19
|
@access_token = access_token
|
|
21
|
-
@refresh_token = refresh_token
|
|
22
|
-
@refresh_token_url = refresh_token_url
|
|
23
20
|
@dpop_handler = DpopHandler.new(private_key, access_token)
|
|
24
|
-
@token_mutex = Mutex.new
|
|
25
21
|
end
|
|
26
22
|
|
|
27
23
|
# Sets a new private key for DPoP token signing
|
|
@@ -32,61 +28,141 @@ module AtProto
|
|
|
32
28
|
@dpop_handler = @dpop_handler.new(private_key, @access_token)
|
|
33
29
|
end
|
|
34
30
|
|
|
35
|
-
# Makes an authenticated HTTP request
|
|
31
|
+
# Makes an authenticated HTTP request
|
|
36
32
|
#
|
|
37
33
|
# @param method [Symbol] The HTTP method to use (:get, :post, etc.)
|
|
38
34
|
# @param url [String] The URL to send the request to
|
|
39
|
-
# @param params [Hash] Optional query parameters
|
|
40
|
-
# @param body [Hash, nil] Optional request body
|
|
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
|
|
41
37
|
#
|
|
42
|
-
# @return [
|
|
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
|
|
43
54
|
#
|
|
44
|
-
# @
|
|
45
|
-
# @
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
56
80
|
)
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
retries += 1
|
|
61
|
-
refresh_access_token!
|
|
62
|
-
retry
|
|
63
|
-
end
|
|
81
|
+
)
|
|
82
|
+
@access_token = response['access_token']
|
|
83
|
+
response
|
|
64
84
|
end
|
|
65
85
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
# Refreshes the access token using the refresh token
|
|
86
|
+
# Refreshes the access token using a refresh token
|
|
69
87
|
#
|
|
70
|
-
# @
|
|
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
|
|
71
93
|
#
|
|
72
|
-
# @
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
80
111
|
)
|
|
112
|
+
)
|
|
113
|
+
@access_token = response['access_token']
|
|
114
|
+
@dpop_handler.access_token = @access_token
|
|
115
|
+
response
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
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
|
|
129
|
+
|
|
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
|
|
137
|
+
|
|
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
|
+
}
|
|
144
|
+
end
|
|
81
145
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
}
|
|
85
155
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
@
|
|
89
|
-
|
|
156
|
+
JWT.encode(
|
|
157
|
+
jwt_payload,
|
|
158
|
+
@private_key,
|
|
159
|
+
'ES256',
|
|
160
|
+
{
|
|
161
|
+
typ: 'jwt',
|
|
162
|
+
alg: 'ES256',
|
|
163
|
+
kid: jwk[:kid]
|
|
164
|
+
}
|
|
165
|
+
)
|
|
90
166
|
end
|
|
91
167
|
end
|
|
92
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
|
|
@@ -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'] == '
|
|
65
|
+
raise TokenExpiredError if body['error'] == 'invalid_token'
|
|
66
66
|
|
|
67
|
-
raise AuthError, "Unauthorized: #{body
|
|
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
|
metadata
CHANGED
|
@@ -1,41 +1,40 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: atproto_client
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- frabr
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-01 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: jwt
|
|
15
14
|
requirement: !ruby/object:Gem::Requirement
|
|
16
15
|
requirements:
|
|
17
|
-
- - "
|
|
16
|
+
- - ">="
|
|
18
17
|
- !ruby/object:Gem::Version
|
|
19
18
|
version: '2.7'
|
|
20
19
|
type: :runtime
|
|
21
20
|
prerelease: false
|
|
22
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
22
|
requirements:
|
|
24
|
-
- - "
|
|
23
|
+
- - ">="
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
25
|
version: '2.7'
|
|
27
26
|
- !ruby/object:Gem::Dependency
|
|
28
27
|
name: openssl
|
|
29
28
|
requirement: !ruby/object:Gem::Requirement
|
|
30
29
|
requirements:
|
|
31
|
-
- - "
|
|
30
|
+
- - ">="
|
|
32
31
|
- !ruby/object:Gem::Version
|
|
33
32
|
version: '3.0'
|
|
34
33
|
type: :runtime
|
|
35
34
|
prerelease: false
|
|
36
35
|
version_requirements: !ruby/object:Gem::Requirement
|
|
37
36
|
requirements:
|
|
38
|
-
- - "
|
|
37
|
+
- - ">="
|
|
39
38
|
- !ruby/object:Gem::Version
|
|
40
39
|
version: '3.0'
|
|
41
40
|
- !ruby/object:Gem::Dependency
|
|
@@ -143,6 +142,7 @@ executables: []
|
|
|
143
142
|
extensions: []
|
|
144
143
|
extra_rdoc_files: []
|
|
145
144
|
files:
|
|
145
|
+
- LICENSE
|
|
146
146
|
- README.md
|
|
147
147
|
- lib/atproto_client.rb
|
|
148
148
|
- lib/atproto_client/client.rb
|
|
@@ -153,7 +153,6 @@ homepage: https://github.com/lasercats/atproto-ruby
|
|
|
153
153
|
licenses:
|
|
154
154
|
- MIT
|
|
155
155
|
metadata: {}
|
|
156
|
-
post_install_message:
|
|
157
156
|
rdoc_options: []
|
|
158
157
|
require_paths:
|
|
159
158
|
- lib
|
|
@@ -168,8 +167,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
168
167
|
- !ruby/object:Gem::Version
|
|
169
168
|
version: '0'
|
|
170
169
|
requirements: []
|
|
171
|
-
rubygems_version: 3.
|
|
172
|
-
signing_key:
|
|
170
|
+
rubygems_version: 3.7.2
|
|
173
171
|
specification_version: 4
|
|
174
172
|
summary: AT Protocol client implementation for Ruby
|
|
175
173
|
test_files: []
|