atproto_client 0.1.3 → 0.1.4
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/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 +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 077b45fcdc42e913ff2054139af44943dfee953afc0eb4f0434af318ed217af4
|
4
|
+
data.tar.gz: a2e99843e274934e303d29b1dc50c58c639da2b81d430e0eda649160788ccdfe
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a700d9f9340ede4ecd2c61c501d2e4731919bea4e02cfeb2c9102bbe502e6d43538387ca3a2f867a06e51ea2daa9b935399b94b6d017a8a72102bcee4822debd
|
7
|
+
data.tar.gz: 5570db2d3df418bd83f30ca6c0ac7047865925d99758024db6027e681199f4389b4a28db8ac96e6ab07455ed3e596dd2311ba01ad5c32ed5b584566ce7d2158a
|
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,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.
|
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
|
+
date: 2024-12-12 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: jwt
|