atproto_client 0.1.2 → 0.1.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +39 -19
- data/lib/atproto_client/client.rb +151 -40
- data/lib/atproto_client/dpop_handler.rb +9 -12
- data/lib/atproto_client/request.rb +3 -3
- data/lib/atproto_client/version.rb +1 -1
- data/lib/atproto_client.rb +0 -1
- metadata +2 -3
- data/lib/atproto_client/configuration.rb +0 -19
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,7 +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.
|
3
|
+
Ruby client for the AT Protocol, with support for oauth/dpop authentication.
|
5
4
|
|
6
5
|
## Installation
|
7
6
|
|
@@ -14,32 +13,53 @@ gem 'atproto_client'
|
|
14
13
|
## Usage
|
15
14
|
|
16
15
|
```ruby
|
17
|
-
# Configure the client (optional)
|
18
|
-
AtProto.configure do |config|
|
19
|
-
config.base_url = "https://bsky.social" # default
|
20
|
-
end
|
21
16
|
|
22
|
-
# Initialize
|
23
|
-
client = AtProto::Client.new(access_token
|
17
|
+
# Initialize with your private key and existing access token
|
18
|
+
client = AtProto::Client.new(private_key:, access_token:)
|
24
19
|
|
25
|
-
#
|
26
|
-
client.
|
20
|
+
# Then request
|
21
|
+
client.request(
|
27
22
|
:get,
|
28
|
-
"
|
29
|
-
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"}
|
30
25
|
)
|
31
26
|
|
32
|
-
#
|
33
|
-
|
34
|
-
|
35
|
-
token_url,
|
27
|
+
# Body and params are optionals
|
28
|
+
# Body will be stringified to json if it's a hash
|
29
|
+
client.request(
|
36
30
|
:post,
|
37
|
-
|
38
|
-
body:
|
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
|
+
}
|
39
40
|
)
|
40
41
|
|
41
|
-
|
42
|
+
# Can make requests with headers and custom body type
|
43
|
+
client.request(
|
44
|
+
:post,
|
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
|
+
}
|
51
|
+
)
|
42
52
|
|
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: )
|
56
|
+
|
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)
|
61
|
+
|
62
|
+
```
|
43
63
|
## Development
|
44
64
|
|
45
65
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
|
@@ -1,57 +1,168 @@
|
|
1
1
|
module AtProto
|
2
|
+
# The Client class handles authenticated HTTP requests to the AT Protocol services
|
3
|
+
# with DPoP token support and token request capabilities.
|
4
|
+
#
|
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
|
+
# @attr_reader [DpopHandler] dpop_handler The handler for DPoP token operations
|
2
8
|
class Client
|
3
|
-
|
9
|
+
attr_accessor :access_token, :dpop_handler
|
4
10
|
|
5
|
-
|
11
|
+
# Initializes a new AT Protocol client
|
12
|
+
#
|
13
|
+
# @param private_key [OpenSSL::PKey::EC] The EC private key used for DPoP token signing (required)
|
14
|
+
# @param access_token [String, nil] Optional access token for authentication
|
15
|
+
#
|
16
|
+
# @raise [ArgumentError] If private_key is not provided or not an OpenSSL::PKey::EC instance
|
17
|
+
def initialize(private_key:, access_token: nil)
|
18
|
+
@private_key = private_key
|
6
19
|
@access_token = access_token
|
7
|
-
@
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
20
|
+
@dpop_handler = DpopHandler.new(private_key, access_token)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Sets a new private key for DPoP token signing
|
24
|
+
#
|
25
|
+
# @param private_key [OpenSSL::PKey::EC] The EC private key to use for signing DPoP tokens (required)
|
26
|
+
# @raise [ArgumentError] If private_key is not an OpenSSL::PKey::EC instance
|
27
|
+
def private_key=(private_key)
|
28
|
+
@dpop_handler = @dpop_handler.new(private_key, @access_token)
|
29
|
+
end
|
30
|
+
|
31
|
+
# Makes an authenticated HTTP request
|
32
|
+
#
|
33
|
+
# @param method [Symbol] The HTTP method to use (:get, :post, etc.)
|
34
|
+
# @param url [String] The URL to send the request to
|
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
|
37
|
+
#
|
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
|
54
|
+
#
|
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
|
22
80
|
)
|
23
|
-
|
24
|
-
|
81
|
+
)
|
82
|
+
@access_token = response['access_token']
|
83
|
+
response
|
84
|
+
end
|
25
85
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
86
|
+
# Refreshes the access token using a refresh token
|
87
|
+
#
|
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
|
93
|
+
#
|
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
|
111
|
+
)
|
112
|
+
)
|
113
|
+
@access_token = response['access_token']
|
114
|
+
@dpop_handler.access_token = @access_token
|
115
|
+
response
|
30
116
|
end
|
31
117
|
|
32
118
|
private
|
33
119
|
|
34
|
-
def
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
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
|
42
129
|
|
43
|
-
|
44
|
-
|
45
|
-
|
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
|
46
137
|
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
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
|
+
}
|
51
144
|
end
|
52
145
|
|
53
|
-
def
|
54
|
-
|
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
|
+
}
|
155
|
+
|
156
|
+
JWT.encode(
|
157
|
+
jwt_payload,
|
158
|
+
@private_key,
|
159
|
+
'ES256',
|
160
|
+
{
|
161
|
+
typ: 'jwt',
|
162
|
+
alg: 'ES256',
|
163
|
+
kid: jwk[:kid]
|
164
|
+
}
|
165
|
+
)
|
55
166
|
end
|
56
167
|
end
|
57
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
|
@@ -63,7 +65,6 @@ module AtProto
|
|
63
65
|
OpenSSL::PKey::EC.generate('prime256v1').tap(&:check_key)
|
64
66
|
end
|
65
67
|
|
66
|
-
# Creates a DPoP token with the specified parameters, encoded by jwk
|
67
68
|
def create_dpop_token(http_method, target_uri, nonce = nil)
|
68
69
|
jwk = JWT::JWK.new(@private_key).export
|
69
70
|
payload = {
|
@@ -73,19 +74,15 @@ module AtProto
|
|
73
74
|
iat: Time.now.to_i,
|
74
75
|
exp: Time.now.to_i + 120
|
75
76
|
}
|
76
|
-
|
77
|
-
# Ajout du hachage du token d'accès si fourni
|
78
|
-
if @access_token
|
79
|
-
token_str = @access_token.to_s
|
80
|
-
sha256 = OpenSSL::Digest.new('SHA256')
|
81
|
-
hash_bytes = sha256.digest(token_str)
|
82
|
-
ath = Base64.urlsafe_encode64(hash_bytes, padding: false)
|
83
|
-
payload[:ath] = ath
|
84
|
-
end
|
85
|
-
|
77
|
+
payload[:ath] = generate_ath if @access_token
|
86
78
|
payload[:nonce] = nonce if nonce
|
87
79
|
|
88
80
|
JWT.encode(payload, @private_key, 'ES256', { typ: 'dpop+jwt', alg: 'ES256', jwk: jwk })
|
89
81
|
end
|
82
|
+
|
83
|
+
def generate_ath
|
84
|
+
hash_bytes = OpenSSL::Digest.new('SHA256').digest(@access_token)
|
85
|
+
Base64.urlsafe_encode64(hash_bytes, padding: false)
|
86
|
+
end
|
90
87
|
end
|
91
88
|
end
|
@@ -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
|
data/lib/atproto_client.rb
CHANGED
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
|
@@ -146,7 +146,6 @@ files:
|
|
146
146
|
- README.md
|
147
147
|
- lib/atproto_client.rb
|
148
148
|
- lib/atproto_client/client.rb
|
149
|
-
- lib/atproto_client/configuration.rb
|
150
149
|
- lib/atproto_client/dpop_handler.rb
|
151
150
|
- lib/atproto_client/request.rb
|
152
151
|
- lib/atproto_client/version.rb
|
@@ -1,19 +0,0 @@
|
|
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
|