File Coverage

lib/At/UserAgent.pm
Criterion Covered Total %
statement 38 62 61.2
branch 0 10 0.0
condition 0 2 0.0
subroutine 13 14 92.8
pod n/a
total 51 88 57.9


line stmt bran cond sub pod time code
1 3     3   43 use v5.42;
  3         13  
2 3     3   21 use feature 'class';
  3         5  
  3         438  
3 3     3   20 no warnings 'experimental::class';
  3         8  
  3         224  
4 3     3   18 use URI;
  3         7  
  3         129  
5 3     3   17 use JSON::PP qw[decode_json encode_json];
  3         6  
  3         279  
6 3     3   20 use Digest::SHA;
  3         5  
  3         122  
7 3     3   42 use MIME::Base64;
  3         17  
  3         231  
8 3     3   2211 use Crypt::JWT;
  3         114493  
  3         219  
9 3     3   30 use Crypt::PRNG;
  3         6  
  3         121  
10 3     3   13 use experimental 'try';
  3         5  
  3         51  
11 3     3   334 use feature 'class';
  3         5  
  3         86  
12 3     3   12 no warnings 'experimental::class';
  3         5  
  3         3431  
13              
14             class At::UserAgent 1.6 {
15             field $accessJwt : reader : param = undef;
16             field $refreshJwt : reader : param = undef;
17             field $token_type : reader : param = 'Bearer';
18             field $dpop_key : reader : param = undef;
19             field $dpop_nonce;
20             field $auth;
21             field $at_protocol_proxy : param = undef;
22              
23             method at_protocol_proxy ( $new_val = undef ) {
24             $at_protocol_proxy = $new_val if defined $new_val;
25             return $at_protocol_proxy;
26             }
27              
28             method dpop_nonce ( $new_val = undef ) {
29             $dpop_nonce = $new_val if defined $new_val;
30             return $dpop_nonce;
31             }
32              
33             method auth ( $new_val = undef ) {
34             $auth = $new_val if defined $new_val;
35             return $auth;
36             }
37              
38             method set_tokens ( $access, $refresh, $type, $key ) {
39             $accessJwt = $access;
40             $refreshJwt = $refresh;
41             $token_type = $type // 'Bearer';
42             $dpop_key = $key;
43             if ( defined $accessJwt ) {
44             $self->_set_auth_header( $token_type . ' ' . $accessJwt );
45             }
46             else {
47             $self->_set_auth_header(undef);
48             }
49             }
50              
51             method _generate_dpop_proof( $url, $method, $skip_ath = 0 ) {
52             return unless $dpop_key;
53             my $jwk_json = $dpop_key->export_key_jwk('public');
54             my $jwk = JSON::PP::decode_json($jwk_json);
55             my $now = time;
56             my $htu = URI->new($url);
57             $htu->query(undef);
58             $htu->fragment(undef);
59             my $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~';
60             my $payload
61             = { jti => Crypt::PRNG::random_string_from( $chars, 32 ), htm => $method, htu => $htu->as_string, iat => $now, exp => $now + 60, };
62             $payload->{nonce} = $dpop_nonce if defined $dpop_nonce;
63              
64             if ( $accessJwt && !$skip_ath ) {
65             $payload->{ath} = MIME::Base64::encode_base64url( Digest::SHA::sha256($accessJwt) );
66             $payload->{ath} =~ s/=+$//;
67             }
68             return Crypt::JWT::encode_jwt( payload => $payload, key => $dpop_key, alg => 'ES256', extra_headers => { typ => 'dpop+jwt', jwk => $jwk } );
69             }
70             method _set_auth_header ($token) { die "Abstract" }
71             method get ( $url, $req = undef ) { die "Abstract" }
72             method post ( $url, $req = undef ) { die "Abstract" }
73             method websocket ( $url, $cb ) { die "Abstract" }
74             }
75             class #
76             At::UserAgent::Tiny : isa(At::UserAgent) {
77 3     3   2751 use HTTP::Tiny;
  3         165321  
  3         12969  
78             field $agent : param
79             = HTTP::Tiny->new( agent => 'At.pm/Tiny', default_headers => { 'Content-Type' => 'application/json', Accept => 'application/json' } );
80              
81             method get( $url, $req = {} ) {
82             $req //= {};
83             $req->{headers}{DPoP} = $self->_generate_dpop_proof( $url, 'GET', $req->{skip_ath} ) if $self->token_type eq 'DPoP';
84             my $res
85             = $agent->get( $url . ( defined $req->{content} && keys %{ $req->{content} } ? '?' . $agent->www_form_urlencode( $req->{content} ) : '' ),
86             { defined $req->{headers} ? ( headers => $req->{headers} ) : () } );
87             $res->{content} = JSON::PP::decode_json( $res->{content} ) if $res->{content} && ( $res->{headers}{'content-type'} // '' ) =~ m[json];
88             unless ( $res->{success} ) {
89             my $msg = $res->{reason} // 'Unknown error';
90             if ( ref $res->{content} eq 'HASH' ) {
91             my $json = $res->{content};
92             my $details = $json->{error} // '';
93             if ( $json->{message} && $json->{message} ne $details ) {
94             $details .= ( $details ? ': ' : '' ) . $json->{message};
95             }
96             $msg .= ": " . $details if $details;
97             $msg .= " - " . $json->{error_description} if $json->{error_description};
98             }
99             elsif ( $res->{content} ) {
100             $msg .= " (" . $res->{content} . ")";
101             }
102             $res->{content} = At::Error->new( message => $msg, fatal => 1 );
103             }
104             wantarray ? ( $res->{content}, $res->{headers} ) : $res->{content};
105             }
106              
107             method post( $url, $req = {} ) {
108             $req //= {};
109             $req->{headers}{DPoP} = $self->_generate_dpop_proof( $url, 'POST', $req->{skip_ath} ) if $self->token_type eq 'DPoP';
110             my $content;
111             if ( defined $req->{content} ) {
112             if ( $req->{encoding} && $req->{encoding} eq 'form' ) {
113             $content = $agent->www_form_urlencode( $req->{content} );
114             $req->{headers}{'Content-Type'} = 'application/x-www-form-urlencoded';
115             }
116             elsif ( ref $req->{content} ) {
117             $content = JSON::PP::encode_json( $req->{content} );
118             $req->{headers}{'Content-Type'} = 'application/json';
119             }
120             else {
121             $content = $req->{content};
122             }
123             }
124             my $res = $agent->post( $url,
125             { defined $req->{headers} ? ( headers => $req->{headers} ) : (), defined $content ? ( content => $content ) : () } );
126             $res->{content} = JSON::PP::decode_json( $res->{content} ) if $res->{content} && ( $res->{headers}{'content-type'} // '' ) =~ m[json];
127             unless ( $res->{success} ) {
128             my $msg = $res->{reason} // 'Unknown error';
129             if ( ref $res->{content} eq 'HASH' ) {
130             my $json = $res->{content};
131             my $details = $json->{error} // '';
132             if ( $json->{message} && $json->{message} ne $details ) {
133             $details .= ( $details ? ': ' : '' ) . $json->{message};
134             }
135             $msg .= ": " . $details if $details;
136             $msg .= " - " . $json->{error_description} if $json->{error_description};
137             }
138             elsif ( $res->{content} ) {
139             $msg .= " (" . $res->{content} . ")";
140             }
141             $res->{content} = At::Error->new( message => $msg, fatal => 1 );
142             }
143             wantarray ? ( $res->{content}, $res->{headers} ) : $res->{content};
144             }
145              
146             method websocket ( $url, $cb ) {
147             die "At::UserAgent::Tiny does not support WebSockets. Please install Mojo::UserAgent.";
148             }
149              
150             method _set_auth_header($token) {
151             $self->auth($token);
152             $agent->{default_headers}{Authorization} = $token;
153             }
154             } class #
155             At::UserAgent::Mojo : isa(At::UserAgent) {
156             field $agent : param = do { require Mojo::UserAgent; Mojo::UserAgent->new };
157              
158             method get( $url, $req = {} ) {
159             $req //= {};
160             my $headers = { %{ $req->{headers} // {} } };
161             if ( $self->at_protocol_proxy ) {
162             $headers->{'atproto-proxy'} = $self->at_protocol_proxy;
163             }
164             $headers->{Authorization} = $self->auth if defined $self->auth;
165             $headers->{DPoP} = $self->_generate_dpop_proof( $url, 'GET', $req->{skip_ath} ) if $self->token_type eq 'DPoP';
166             if ( $ENV{DEBUG} ) {
167             say "[DEBUG] [At] GET $url";
168             say "[DEBUG] [At] Headers: " . JSON::PP::encode_json($headers);
169             }
170             my $tx = $agent->get( $url, $headers, defined $req->{content} ? ( form => $req->{content} ) : () );
171             my $res = $tx->result;
172             if ( my $nonce = $res->headers->header('DPoP-Nonce') ) { $self->dpop_nonce($nonce); }
173             if ( $ENV{DEBUG} ) {
174             say "[DEBUG] [At] Response Code: " . $res->code;
175             say "[DEBUG] [At] Response Headers: " . JSON::PP::encode_json( $res->headers->to_hash );
176             }
177             if ( $res->code == 401 || $res->code == 400 ) {
178             my $body = $res->body // '';
179             if ( $body =~ /use_dpop_nonce/i ) {
180             say "[DEBUG] [At] Retrying with fresh DPoP nonce..." if $ENV{DEBUG};
181             $headers->{DPoP} = $self->_generate_dpop_proof( $url, 'GET', $req->{skip_ath} ) if $self->token_type eq 'DPoP';
182             if ( $ENV{DEBUG} ) {
183             say "[DEBUG] [At] GET (Retry) $url";
184             say "[DEBUG] [At] Headers (Retry): " . JSON::PP::encode_json($headers);
185             }
186             $tx = $agent->get( $url, $headers, defined $req->{content} ? ( form => $req->{content} ) : () );
187             $res = $tx->result;
188             if ( my $nonce = $res->headers->header('DPoP-Nonce') ) { $self->dpop_nonce($nonce); }
189             if ( $ENV{DEBUG} ) {
190             say "[DEBUG] [At] Response Code (Retry): " . $res->code;
191             say "[DEBUG] [At] Response Headers (Retry): " . JSON::PP::encode_json( $res->headers->to_hash );
192             }
193             }
194             }
195             if ( $res->is_success ) {
196             my $content = $res->body ? ( $res->headers->content_type // '' ) =~ m[json] ? $res->json : $res->body : ();
197             return wantarray ? ( $content, $res->headers->to_hash ) : $content;
198             }
199             my $msg = $res->message;
200             if ( my $body = $res->body ) {
201             my $json;
202             try { $json = JSON::PP::decode_json($body) }
203             catch ($e) { }
204             if ($json) {
205             my $details = $json->{error} // '';
206             if ( $json->{message} && $json->{message} ne $details ) {
207             $details .= ( $details ? ': ' : '' ) . $json->{message};
208             }
209             $msg .= ": " . $details if $details;
210             $msg .= " - " . $json->{error_description} if $json->{error_description};
211             }
212             else {
213             $msg .= " (" . $body . ")";
214             }
215             }
216             return At::Error->new( message => $msg, fatal => 1 );
217             }
218              
219             method post( $url, $req = {} ) {
220             $req //= {};
221             my $headers = { %{ $req->{headers} // {} } };
222             if ( $self->at_protocol_proxy ) {
223             $headers->{'atproto-proxy'} = $self->at_protocol_proxy;
224             }
225             $headers->{Authorization} = $self->auth if defined $self->auth;
226             $headers->{DPoP} = $self->_generate_dpop_proof( $url, 'POST', $req->{skip_ath} ) if $self->token_type eq 'DPoP';
227             if ( $ENV{DEBUG} ) {
228             say "[DEBUG] [At] POST $url";
229             say "[DEBUG] [At] Headers: " . JSON::PP::encode_json($headers);
230             }
231             my %args;
232             if ( defined $req->{content} ) {
233             if ( $req->{encoding} && $req->{encoding} eq 'form' ) { $args{form} = $req->{content}; }
234             elsif ( ref $req->{content} ) { $args{json} = $req->{content}; }
235             else { $args{content} = $req->{content}; }
236             }
237             my $tx = $agent->post( $url, $headers, %args );
238             my $res = $tx->result;
239             if ( my $nonce = $res->headers->header('DPoP-Nonce') ) { $self->dpop_nonce($nonce); }
240             if ( $ENV{DEBUG} ) {
241             say "[DEBUG] [At] Response Code: " . $res->code;
242             say "[DEBUG] [At] Response Headers: " . JSON::PP::encode_json( $res->headers->to_hash );
243             }
244             if ( $res->code == 401 || $res->code == 400 ) {
245             my $body = $res->body // '';
246             if ( $body =~ /use_dpop_nonce/i ) {
247             say "[DEBUG] [At] Retrying with fresh DPoP nonce..." if $ENV{DEBUG};
248             $headers->{DPoP} = $self->_generate_dpop_proof( $url, 'POST', $req->{skip_ath} ) if $self->token_type eq 'DPoP';
249             if ( $ENV{DEBUG} ) {
250             say "[DEBUG] [At] POST (Retry) $url";
251             say "[DEBUG] [At] Headers (Retry): " . JSON::PP::encode_json($headers);
252             }
253             $tx = $agent->post( $url, $headers, %args );
254             $res = $tx->result;
255             if ( my $nonce = $res->headers->header('DPoP-Nonce') ) { $self->dpop_nonce($nonce); }
256             if ( $ENV{DEBUG} ) {
257             say "[DEBUG] [At] Response Code (Retry): " . $res->code;
258             say "[DEBUG] [At] Response Headers (Retry): " . JSON::PP::encode_json( $res->headers->to_hash );
259             }
260             }
261             }
262             if ( $res->is_success ) {
263             my $content = $res->body ? ( $res->headers->content_type // '' ) =~ m[json] ? $res->json : $res->body : ();
264             return wantarray ? ( $content, $res->headers->to_hash ) : $content;
265             }
266             my $msg = $res->message;
267             if ( my $body = $res->body ) {
268             my $json;
269             try { $json = JSON::PP::decode_json($body) }
270             catch ($e) { }
271             if ($json) {
272             my $details = $json->{error} // '';
273             if ( $json->{message} && $json->{message} ne $details ) {
274             $details .= ( $details ? ': ' : '' ) . $json->{message};
275             }
276             $msg .= ": " . $details if $details;
277             $msg .= " - " . $json->{error_description} if $json->{error_description};
278             }
279             else {
280             $msg .= " (" . $body . ")";
281             }
282             }
283             return At::Error->new( message => $msg, fatal => 1 );
284             }
285              
286             method websocket( $url, $cb ) {
287             $agent->inactivity_timeout(0); # Disable inactivity timeout for firehose
288 0           $agent->websocket(
289 0     0     $url => sub ( $ua, $tx ) {
  0            
  0            
290 0 0         if ( !$tx->is_websocket ) {
291 0           $cb->( undef, At::Error->new( message => "WebSocket handshake failed", fatal => 0 ) );
292 0           return;
293             }
294              
295             # Keep-alive heartbeat every 20 seconds
296             my $id = Mojo::IOLoop->recurring(
297             20 => sub {
298 0 0         return unless $tx;
299 0           $tx->send( [ 1, 0, 0, 0, 9, '' ] ); # Raw Ping frame
300             }
301 0           );
302              
303             # Activity watchdog: if we don't get a message for 10 seconds, close and reconnect
304 0           my $watchdog;
305             my $reset_watchdog = sub {
306 0 0         Mojo::IOLoop->remove($watchdog) if defined $watchdog;
307             $watchdog = Mojo::IOLoop->timer(
308             10 => sub {
309 0           $tx->finish( 4000, "Watchdog timeout" );
310             }
311 0           );
312 0           };
313 0           $reset_watchdog->();
314             $tx->on(
315             message => sub ( $tx, $msg ) {
316 0           $reset_watchdog->();
317 0           $cb->( $msg, undef );
318             }
319 0           );
320             $tx->on(
321             finish => sub ( $tx, $code, $reason ) {
322 0 0         Mojo::IOLoop->remove($id) if defined $id;
323 0 0         Mojo::IOLoop->remove($watchdog) if defined $watchdog;
324 0   0       $cb->( undef, At::Error->new( message => "WebSocket finished: $code " . ( $reason // '' ), fatal => 0 ) );
325 0           $tx = undef;
326             }
327 0           );
328             }
329             );
330             }
331             method _set_auth_header($token) { $self->auth($token); }
332             } 1;
333             __END__