File Coverage

blib/lib/Net/Async/WebSearch/Provider/Reddit/OAuth.pm
Criterion Covered Total %
statement 143 148 96.6
branch 52 68 76.4
condition 30 60 50.0
subroutine 30 30 100.0
pod 12 14 85.7
total 267 320 83.4


line stmt bran cond sub pod time code
1             package Net::Async::WebSearch::Provider::Reddit::OAuth;
2             our $VERSION = '0.002';
3             # ABSTRACT: Reddit search provider using the OAuth2 endpoint
4 2     2   1664 use strict;
  2         3  
  2         69  
5 2     2   8 use warnings;
  2         2  
  2         137  
6 2     2   10 use parent 'Net::Async::WebSearch::Provider::Reddit';
  2         3  
  2         12  
7              
8 2     2   90 use Carp qw( croak );
  2         3  
  2         71  
9 2     2   7 use Future;
  2         14  
  2         87  
10 2     2   8 use JSON::MaybeXS qw( decode_json );
  2         2  
  2         60  
11 2     2   6 use URI;
  2         10  
  2         29  
12 2     2   5 use HTTP::Request::Common qw( GET POST );
  2         2  
  2         68  
13 2     2   478 use MIME::Base64 qw( encode_base64 );
  2         690  
  2         3136  
14              
15             sub _init {
16 16     16   24 my ( $self ) = @_;
17 16 100       215 croak "Reddit::OAuth requires 'client_id'" unless $self->{client_id};
18 15 100       144 croak "Reddit::OAuth requires 'client_secret'" unless defined $self->{client_secret};
19              
20 14   100     33 $self->{grant_type} ||= 'client_credentials'; # client_credentials | password | installed | authorization_code
21 14   50     50 $self->{token_url} ||= 'https://www.reddit.com/api/v1/access_token';
22 14   50     41 $self->{authorize_url} ||= 'https://www.reddit.com/api/v1/authorize';
23 14   50     74 $self->{endpoint} ||= 'https://oauth.reddit.com';
24 14   50     39 $self->{name} ||= 'reddit-oauth';
25 14   50     41 $self->{link_base} ||= 'https://www.reddit.com';
26 14   100     45 $self->{token_margin} ||= 60; # refresh this many seconds before expiry
27              
28 14 100       43 if ( $self->{grant_type} eq 'password' ) {
    100          
    100          
29             croak "grant_type=password needs 'username' and 'password'"
30 2 100 66     131 unless defined $self->{username} && defined $self->{password};
31             }
32             elsif ( $self->{grant_type} eq 'installed' ) {
33 1   50     3 $self->{device_id} ||= 'DO_NOT_TRACK_THIS_DEVICE';
34             }
35             elsif ( $self->{grant_type} eq 'authorization_code' ) {
36             # For authorization_code, either the caller seeds us with
37             # a refresh_token (persisted from a prior session) or with
38             # an access_token + refresh_token pair, or they must drive
39             # authorize_url → complete_authorization themselves before
40             # the first search.
41             }
42              
43 13         40 $self->SUPER::_init;
44             # Parent sets link_base to www.reddit.com, which is what we want.
45             # But parent also sets endpoint to www.reddit.com — overwrite back.
46 13 50       25 $self->{endpoint} = 'https://oauth.reddit.com' unless $self->{_endpoint_explicit};
47              
48             # Pre-seed token/refresh if the caller provided them (persisted state).
49 13 100       27 if ( defined $self->{access_token} ) {
50 2         4 $self->{_access_token} = $self->{access_token};
51             $self->{_token_expires_at} = $self->{token_expires_at}
52 2   0     48 // ( time + ( $self->{expires_in} // 3600 ) );
      33        
53             }
54 13 100       25 if ( defined $self->{refresh_token} ) {
55 2         6 $self->{_refresh_token} = $self->{refresh_token};
56             }
57             }
58              
59             sub new {
60 16     16 0 9033 my ( $class, %args ) = @_;
61 16         32 my $explicit_ep = exists $args{endpoint};
62 16         61 my $self = $class->SUPER::new(%args);
63 13         22 $self->{_endpoint_explicit} = $explicit_ep;
64 13 50       21 $self->{endpoint} = 'https://oauth.reddit.com' unless $explicit_ep;
65 13         30 return $self;
66             }
67              
68 12     12 1 33 sub client_id { $_[0]->{client_id} }
69 10     10 1 19 sub client_secret { $_[0]->{client_secret} }
70 28     28 1 59 sub grant_type { $_[0]->{grant_type} }
71 10     10 1 31 sub token_url { $_[0]->{token_url} }
72 2     2 0 9 sub authorize_endpoint { $_[0]->{authorize_url} }
73              
74 2     2 1 736 sub access_token { $_[0]->{_access_token} }
75 2     2 1 8 sub refresh_token { $_[0]->{_refresh_token} }
76 1     1 1 5 sub token_expires_at { $_[0]->{_token_expires_at} }
77              
78             # MCP / host helper: snapshot the persistable bits of the auth state
79             # so a host application can stash them in its session store and feed
80             # them back to a later `new()` call.
81             sub token_state {
82 1     1 1 45 my ( $self ) = @_;
83             return {
84             ( defined $self->{_access_token}
85             ? ( access_token => $self->{_access_token},
86             token_expires_at => $self->{_token_expires_at} )
87             : () ),
88             ( defined $self->{_refresh_token}
89             ? ( refresh_token => $self->{_refresh_token} )
90 1 50       7 : () ),
    50          
91             };
92             }
93              
94             sub _basic_auth_header {
95 10     10   17 my ( $self ) = @_;
96 10         21 my $raw = $self->client_id . ':' . $self->client_secret;
97 10         29 my $b64 = encode_base64($raw, '');
98 10         27 return "Basic $b64";
99             }
100              
101             #----------------------------------------------------------------------
102             # Host-facing authorization-code helpers
103             #----------------------------------------------------------------------
104              
105             # Build the URL the human should visit to authorize the app.
106             # Returns a plain URL string (not a Future) — no network round-trip.
107             sub authorize_url {
108 2     2 1 3641 my ( $self, %args ) = @_;
109 2 50       6 croak "authorize_url needs 'redirect_uri'" unless defined $args{redirect_uri};
110 2         3 my $scope = $args{scope};
111 2 100       7 if ( ref $scope eq 'ARRAY' ) { $scope = join ' ', @$scope }
  1         3  
112 2   100     6 $scope //= 'read';
113 2         6 my $uri = URI->new( $self->authorize_endpoint );
114             $uri->query_form(
115             client_id => $self->client_id,
116             response_type => 'code',
117             state => $args{state} // _random_state(),
118             redirect_uri => $args{redirect_uri},
119 2   66     126 duration => $args{duration} // 'permanent',
      50        
120             scope => $scope,
121             );
122 2         256 return $uri->as_string;
123             }
124              
125             # Exchange an authorization_code → access_token + refresh_token.
126             # Caller supplies the code pasted back from the redirect, plus the
127             # same redirect_uri that was used in authorize_url.
128             sub complete_authorization {
129 2     2 1 6 my ( $self, %args ) = @_;
130 2   33     8 my $http = $args{http} || $self->{_http_for_auth};
131 2 50       4 croak "complete_authorization needs 'http' (Net::Async::HTTP) "
132             ."unless the provider has been added to a loop first"
133             unless $http;
134 2 50       4 croak "complete_authorization needs 'code'" unless defined $args{code};
135 2 50       4 croak "complete_authorization needs 'redirect_uri'" unless defined $args{redirect_uri};
136              
137             return $self->_post_token($http, [
138             grant_type => 'authorization_code',
139             code => $args{code},
140             redirect_uri => $args{redirect_uri},
141 2         7 ]);
142             }
143              
144             sub _random_state {
145 1     1   12 my @chars = ( 'a'..'z', 'A'..'Z', 0..9 );
146 1         3 join '', map { $chars[ int rand @chars ] } 1 .. 24;
  24         122  
147             }
148              
149             #----------------------------------------------------------------------
150             # Token fetch / refresh dance
151             #----------------------------------------------------------------------
152              
153             sub _post_token {
154 10     10   18 my ( $self, $http, $body_ref ) = @_;
155 10         16 my $req = POST( $self->token_url, $body_ref );
156 10         10709 $req->header( 'Authorization' => $self->_basic_auth_header );
157 10         316 $req->header( 'User-Agent' => $self->user_agent_string );
158 10         328 $req->header( 'Accept' => 'application/json' );
159              
160             return $http->do_request( request => $req )->then(sub {
161 10     10   3531 my ( $resp ) = @_;
162 10 100       29 unless ( $resp->is_success ) {
163 1         9 return Future->fail(
164             $self->name.": token HTTP ".$resp->status_line, 'websearch', $self->name,
165             );
166             }
167 9         54 my $data = eval { decode_json( $resp->decoded_content ) };
  9         24  
168 9 50       1025 if ( my $e = $@ ) {
169 0         0 return Future->fail( $self->name.": token invalid JSON: $e", 'websearch', $self->name );
170             }
171 9 50       20 if ( !$data->{access_token} ) {
172 0   0     0 my $err = $data->{error} // 'unknown';
173 0         0 return Future->fail( $self->name.": token error: $err", 'websearch', $self->name );
174             }
175 9         16 $self->{_access_token} = $data->{access_token};
176 9   50     26 $self->{_token_expires_at} = time + ( $data->{expires_in} // 3600 );
177             # Reddit only returns refresh_token on duration=permanent auth_code
178             # flows. Some refresh responses include a fresh refresh_token too —
179             # prefer that; otherwise keep the one we used.
180 9 100       20 if ( defined $data->{refresh_token} ) {
181 2         5 $self->{_refresh_token} = $data->{refresh_token};
182             }
183 9 100       19 if ( my $cb = $self->{on_token_refresh} ) {
184 1         3 $cb->( $self, $data );
185             }
186 9         27 return Future->done( $self->{_access_token} );
187 10         335 });
188             }
189              
190             sub _get_token {
191 11     11   13 my ( $self, $http ) = @_;
192              
193             # Valid cached token?
194 11 100 66     35 if ( $self->{_access_token}
      66        
195             && $self->{_token_expires_at}
196             && time + $self->{token_margin} < $self->{_token_expires_at} ) {
197 2         9 return Future->done( $self->{_access_token} );
198             }
199              
200             # A refresh_token beats every other grant — it's cheap and user-context
201             # is already established.
202 9 100       16 if ( $self->{_refresh_token} ) {
203             return $self->_post_token($http, [
204             grant_type => 'refresh_token',
205             refresh_token => $self->{_refresh_token},
206 1         4 ]);
207             }
208              
209             # For authorization_code flows without a refresh_token, we can't
210             # auto-fetch — the caller must drive the authorize → complete
211             # dance first.
212 8 100       16 if ( $self->grant_type eq 'authorization_code' ) {
213 1         4 return Future->fail(
214             $self->name.": no access_token and no refresh_token — "
215             ."call authorize_url / complete_authorization first",
216             'websearch', $self->name,
217             );
218             }
219              
220 7         12 my @body = ( grant_type => $self->grant_type );
221 7 100       10 if ( $self->grant_type eq 'password' ) {
    100          
222 1         4 push @body, username => $self->{username}, password => $self->{password};
223             }
224             elsif ( $self->grant_type eq 'installed' ) {
225             @body = (
226             grant_type => 'https://oauth.reddit.com/grants/installed_client',
227             device_id => $self->{device_id},
228 1         3 );
229             }
230              
231 7         16 return $self->_post_token($http, \@body);
232             }
233              
234             sub search {
235 11     11 1 21 my ( $self, $http, $query, $opts ) = @_;
236 11   50     24 $opts ||= {};
237 11   50     17 my $limit = $opts->{limit} || 10;
238              
239             return $self->_get_token($http)->then(sub {
240 9     9   671 my ( $token ) = @_;
241              
242 9 50       39 my $sub = defined $opts->{subreddit} ? $opts->{subreddit} : $self->subreddit;
243 9 50 33     23 my $path = defined $sub && length $sub ? "/r/$sub/search" : "/search";
244              
245 9         45 my $uri = URI->new( $self->endpoint . $path );
246             my %q = (
247             q => $query,
248             limit => $limit,
249             sort => $opts->{sort} // $self->sort,
250 9   33     574 t => $opts->{time} // $self->time,
      33        
251             raw_json => 1,
252             );
253 9 50 33     21 $q{restrict_sr} = 1 if defined $sub && length $sub;
254 9 50       24 $q{include_over_18} = $opts->{include_nsfw} ? 'on' : 'off';
255 9         38 $uri->query_form(%q);
256              
257 9         874 my $req = GET( $uri->as_string );
258 9         941 $req->header( 'Authorization' => "Bearer $token" );
259 9         353 $req->header( 'User-Agent' => $self->user_agent_string );
260 9         277 $req->header( 'Accept' => 'application/json' );
261              
262             $http->do_request( request => $req )->then(sub {
263 9         2481 my ( $resp ) = @_;
264 9 50       22 unless ( $resp->is_success ) {
265 0         0 return Future->fail(
266             $self->name.": HTTP ".$resp->status_line, 'websearch', $self->name,
267             );
268             }
269 9         48 my $data = eval { decode_json( $resp->decoded_content ) };
  9         20  
270 9 50       885 if ( my $e = $@ ) {
271 0         0 return Future->fail( $self->name.": invalid JSON: $e", 'websearch', $self->name );
272             }
273 9         27 return Future->done( $self->_parse_listing($data, $limit) );
274 9         272 });
275 11         31 });
276             }
277              
278             sub user_agent_string {
279 19     19 1 28 my ( $self ) = @_;
280 19 100       53 return $self->{user_agent} if defined $self->{user_agent};
281 17         46 return $self->SUPER::user_agent_string;
282             }
283              
284             1;
285              
286             __END__