File Coverage

blib/lib/Plack/Auth/SSO/ORCID.pm
Criterion Covered Total %
statement 36 104 34.6
branch 0 26 0.0
condition 0 3 0.0
subroutine 12 17 70.5
pod 0 2 0.0
total 48 152 31.5


line stmt bran cond sub pod time code
1             package Plack::Auth::SSO::ORCID;
2              
3 1     1   83830 use strict;
  1         10  
  1         24  
4 1     1   469 use utf8;
  1         12  
  1         4  
5 1     1   28 use feature qw(:5.10);
  1         2  
  1         122  
6 1     1   385 use Data::Util qw(:check);
  1         658  
  1         147  
7 1     1   450 use Moo;
  1         9362  
  1         4  
8 1     1   1632 use Plack::Request;
  1         71068  
  1         31  
9 1     1   367 use Plack::Session;
  1         947  
  1         25  
10 1     1   5 use URI;
  1         2  
  1         17  
11 1     1   550 use LWP::UserAgent;
  1         36476  
  1         33  
12 1     1   416 use WWW::ORCID;
  1         18815  
  1         30  
13 1     1   544 use JSON;
  1         7021  
  1         5  
14 1     1   509 use Plack::Auth::SSO::ResponseParser::ORCID;
  1         18  
  1         1292  
15              
16             our $VERSION = "0.0137";
17              
18             with "Plack::Auth::SSO";
19              
20             has sandbox => (
21                 is => "ro",
22                 required => 0
23             );
24             has public => (
25                 is => "ro",
26                 required => 0,
27                 lazy => 1,
28                 default => sub { 1; }
29             );
30             has scope => (
31                 is => "lazy"
32             );
33             has client_id => (
34                 is => "ro",
35                 isa => sub { is_string($_[0]) or die("client_id should be string"); },
36                 required => 1
37             );
38             has client_secret => (
39                 is => "ro",
40                 isa => sub { is_string($_[0]) or die("client_secret should be string"); },
41                 required => 1
42             );
43              
44             has orcid => (
45                 is => "lazy",
46                 init_arg => undef
47             );
48             has response_parser => (
49                 is => "ro",
50                 lazy => 1,
51                 default => sub {
52                     Plack::Auth::SSO::ResponseParser::ORCID->new();
53                 },
54                 init_arg => undef
55             );
56             has json => (
57                 is => "ro",
58                 lazy => 1,
59                 default => sub {
60                     JSON->new->utf8(1);
61                 },
62                 init_arg => undef
63             );
64              
65             sub _build_orcid {
66              
67 0     0         my $self = $_[0];
68              
69 0               WWW::ORCID->new(
70                     client_id => $self->client_id(),
71                     client_secret => $self->client_secret(),
72                     sandbox => $self->sandbox(),
73                     public => $self->public(),
74                     transport => "LWP"
75                 );
76              
77             }
78              
79             sub _build_scope {
80 0     0         my $self = $_[0];
81 0 0             $self->public() ? "/authenticate" : "/read-limited";
82             }
83              
84             sub redirect_to_login {
85              
86 0     0 0       my ( $self, $request, $session ) = @_;
87              
88 0               my $state = $self->generate_csrf_token();
89 0               $self->set_csrf_token(
90                     $session,
91                     $state
92                 );
93              
94 0               my $request_uri = $request->request_uri();
95 0               my $idx = index( $request_uri, "?" );
96 0 0             if ( $idx >= 0 ) {
97              
98 0                   $request_uri = substr( $request_uri, 0, $idx );
99              
100                 }
101              
102 0               my $redirect_uri = URI->new( $self->uri_for($request_uri) );
103 0               $redirect_uri->query_form( state => $state );
104              
105 0               my $auth_uri = $self->orcid->authorize_url(
106                     show_login => "true",
107                     scope => $self->scope(),
108                     response_type => "code",
109                     redirect_uri => $redirect_uri->as_string()
110                 );
111              
112 0               $self->log()->info(
113                     "redirecting to orcid login $auth_uri"
114                 );
115              
116 0               [ 302, [ Location => $auth_uri ], [] ];
117              
118             }
119              
120             sub to_app {
121 0     0 0       my $self = $_[0];
122              
123                 sub {
124              
125 0     0             state $orcid = $self->orcid;
126 0                   state $json = $self->json;
127 0                   state $response_parser = $self->response_parser;
128              
129 0                   my $env = $_[0];
130              
131 0                   my $request = Plack::Request->new($env);
132 0                   my $session = Plack::Session->new($env);
133 0                   my $params = $request->query_parameters();
134              
135 0                   my $auth_sso = $self->get_auth_sso($session);
136              
137 0 0                 if( $self->log()->is_debug() ){
138              
139 0                       $self->log()->debugf( "incoming query parameters: %s", [$params->flatten] );
140 0                       $self->log()->debugf( "session: %s", $session->dump() );
141 0                       $self->log()->debugf( "session_key for auth_sso: %s ", $self->session_key() );
142              
143                     }
144              
145             #already got here before
146 0 0                 if ( is_hash_ref($auth_sso) ) {
147              
148 0                       $self->log()->debug( "auth_sso already present" );
149              
150 0                       return $self->redirect_to_authorization();
151              
152                     }
153              
154 0                   my $code = $params->get("code");
155 0                   my $state = $params->get("state");
156              
157             #callback phase
158 0 0 0               if ( is_string($code) && $self->csrf_token_valid($session,$state) ) {
    0          
159              
160 0                       $self->log()->debug(
161                             "parameter code and state present: entering callback phase"
162                         );
163              
164 0                       $self->cleanup( $session );
165              
166 0                       my $error = $params->get("error");
167 0                       my $error_description = $params->get("error_description");
168              
169 0 0                     if ( is_string($error) ) {
170              
171 0 0                         return $self->redirect_to_login( $request, $session ) if $error eq "401";
172              
173 0                           $self->set_auth_sso_error( $session, {
174                                 package => __PACKAGE__,
175                                 package_id => $self->id,
176                                 type => $error,
177                                 content => $error_description
178                             });
179 0                           return $self->redirect_to_error();
180              
181                         }
182              
183             #access_token returns either a hash (from ORCID), or undef
184 0                       my $res = $orcid->access_token(
185                             grant_type => "authorization_code",
186                             code => $code
187                         );
188              
189 0 0                     unless ( $res ) {
190              
191 0                           my( $code, $h, $body ) = @{ $orcid->last_error };
  0            
192 0                           my %headers = %$h;
193              
194 0 0                         if ( index( $headers{"content-type"}, "json" ) >= 0 ) {
195              
196 0                               my $orcid_res = $json->decode( $body );
197              
198                                 $self->log()->errorf(
199                                     "unable to retrieve access token. Error: %s, Error description: %s",
200                                     $orcid_res->{error},
201                                     $orcid_res->{error_description}
202 0 0                             ) if $self->log()->is_error();
203              
204 0 0                             return $self->redirect_to_login( $request, $session ) if $orcid_res->{error} eq "401";
205              
206                                 $self->set_auth_sso_error( $session, {
207                                     package => __PACKAGE__,
208                                     package_id => $self->id,
209                                     type => $orcid_res->{error},
210                                     content => $orcid_res->{error_description}
211 0                               });
212              
213                             }
214                             else {
215              
216 0 0                             $self->log()->errorf(
217                                     "unable to retrieve access token. See full body in auth_sso_error."
218                                 ) if $self->log()->is_error();
219              
220 0                               $self->set_auth_sso_error( $session, {
221                                     package => __PACKAGE__,
222                                     package_id => $self->id,
223                                     type => "unknown",
224                                     content => $body
225                                 });
226              
227                             }
228              
229 0                           return $self->redirect_to_error();
230              
231                         }
232              
233             #request person data..
234 0                       my $res2 = $orcid->person( token => $res->{access_token}, orcid => $res->{orcid} );
235              
236                         $self->set_auth_sso(
237                             $session,
238                             {
239                                 %{
240 0                                   $response_parser->parse( $res, $res2 )
  0            
241                                 },
242                                 package => __PACKAGE__,
243                                 package_id => $self->id,
244                                 response => {
245                                     content => $json->encode( $res ),
246                                     content_type => "application/json"
247                                 }
248                             }
249                         );
250              
251 0                       return $self->redirect_to_authorization();
252              
253                     }
254              
255                     elsif( is_string($code) ) {
256              
257 0                       $self->log()->error( "parameter code given, but csrf token not present or invalid" );
258              
259 0                       $self->cleanup( $session );
260              
261 0                       $self->set_auth_sso_error( $session,{
262                             package => __PACKAGE__,
263                             package_id => $self->id,
264                             type => "CSRF_DETECTED",
265                             content => "CSRF_DETECTED"
266                         });
267 0                       return $self->redirect_to_error();
268              
269                     }
270              
271             #request phase
272                     else {
273              
274 0                       $self->log()->debug(
275                             "neither code nor state given: entering request phase"
276                         );
277              
278 0                       $self->cleanup( $session );
279              
280 0                       $self->redirect_to_login( $request, $session );
281              
282                     }
283 0               };
284             }
285              
286             1;
287              
288             =pod
289            
290             =head1 NAME
291            
292             Plack::Auth::SSO::ORCID - implementation of Plack::Auth::SSO for ORCID
293            
294             =head1 SYNOPSIS
295            
296             #in your app.psgi
297            
298             builder {
299            
300             #Register THIS URI in ORCID as a new redirect_uri
301             mount "/auth/orcid" => Plack::Auth::SSO::ORCID->new(
302             client_id => "APP-1",
303             client_secret => "mypassword",
304             sandbox => 1,
305             uri_base => "http://localhost:5000",
306             authorization_path => "/auth/orcid/callback",
307             error_path => "/auth/error"
308             )->to_app;
309            
310             #DO NOT register this uri as new redirect_uri in ORCID
311             mount "/auth/orcid/callback" => sub {
312            
313             my $env = shift;
314             my $session = Plack::Session->new($env);
315             my $auth_sso = $session->get("auth_sso");
316            
317             #not authenticated yet
318             unless( $auth_sso ){
319            
320             return [ 403, ["Content-Type" => "text/html"], ["forbidden"] ];
321            
322             }
323            
324             #process auth_sso (white list, roles ..)
325            
326             #auth_sso is a hash reference:
327             #{
328             # package => "Plack::Auth::SSO::ORCID",
329             # package_id => "Plack::Auth::SSO::ORCID",
330             # response => {
331             # content_type => "application/json",
332             # content => ""{\"orcid\":\"0000-0002-5268-9669\",\"token_type\":\"bearer\",\"name\":\"Nicolas Franck\",\"refresh_token\":\"222222222222\",\"access_token\":\"111111111111\",\"scope\":\"/authenticate\",\"expires_in\":631138518}
333             # },
334             # uid => "0000-0002-5268-9669",
335             # info => {
336             # name => "Nicolas Franck",
337             # first_name => "Nicolas",
338             # last_name => "Franck",
339             # email => "nicolas.franck@nowhere.com",
340             # location => "BE",
341             # description => "my biography",
342             # other_names => [ "njfranck" ],
343             # urls => [ { mysite => "https://mysite.com" } ],
344             # external_identifiers => []
345             # },
346             # extra => {}
347             #}
348            
349             #you can reuse the "orcid" and "access_token" to get the user profile
350            
351             [ 200, ["Content-Type" => "text/html"], ["logged in!"] ];
352            
353             };
354            
355             mount "/auth/error" => sub {
356            
357             my $env = shift;
358             my $session = Plack::Session->new($env);
359             my $auth_sso_error = $session->get("auth_sso_error");
360            
361             unless ( $auth_sso_error ) {
362            
363             return [ 302, [ Location => $self->uri_for( "/" ) ], [] ];
364            
365             }
366            
367             [ 200, [ "Content-Type" => "text/plain" ], [
368             "Something went wrong. User could not be authenticated against CAS\n",
369             "Please report this error:\n",
370             $auth_sso_error->{content}
371             ]];
372            
373             };
374            
375             };
376            
377            
378             =head1 DESCRIPTION
379            
380             This is an implementation of L<Plack::Auth::SSO> to authenticate against a ORCID (OAuth) server.
381            
382             It inherits all configuration options from its parent.
383            
384             Remember that this module only performs these steps:
385            
386             * redirect to ORCID authorize url
387            
388             * exchange 1: exchange authorization code for access token ( orcid and name known )
389            
390             This delivers a hash containing orcid, name, access_token and a refresh_token.
391            
392             * exchange 2: exchange access_token for person information
393            
394             This delivers a hash containing detailed information.
395            
396             So we actually retrieved two hashes.
397            
398             Those steps provide the following information:
399            
400             * uid: derived from "orcid" from the first hash
401            
402             * info.name: derived from "name" from the first hash
403            
404             * other keys in "info" are extracted from the second hash
405            
406             * info.first_name: string
407            
408             * info.last_name: string
409            
410             * info.other_names: array of strings
411            
412             * info.email: first primary and verified email
413            
414             * info.description: string. Retrieved from "biography".
415            
416             * info.location: country code
417            
418             * info.url: array of objects
419            
420             * info.external_identifiers: array of objects
421            
422             * extra: both hashes from the first and second call to ORCID are merged. For obvious reasons "orcid" is not present, and neither is "name" because it has a conflicting format between the two responses.
423            
424             extra.access_token contains a code that conforms to the scope requested (see below).
425            
426             If you want to request additional information from ORCID
427            
428             =head1 CONFIG
429            
430             Register the uri of this application in ORCID as a new redirect_uri.
431            
432             DO NOT register the authorization_path in ORCID as the redirect_uri!
433            
434             =over 4
435            
436             =item client_id
437            
438             client_id for your application (see developer credentials from ORCID)
439            
440             =item client_secret
441            
442             client_secret for your application (see developer credentials from ORCID)
443            
444             =item sandbox
445            
446             0|1. Defaults to 0. When set to 1, this api makes use of http://sandbox.orcid.org instead of http://orcid.org.
447            
448             =item public
449            
450             0|1. Defaults to 1. 0 means you're using the member API.
451            
452             =item scope
453            
454             Requested scope. When not set, the parameter "public" will decide the value:
455            
456             * public 1 : scope is "/authenticate"
457            
458             * public 0 : scope is "/read-limited"
459            
460             Please consult the ORCID to make sure that the parameters "public" and "scope" do not clash.
461             (e.g. public is 1 and scope is "/read-limited")
462            
463             =back
464            
465             =head1 LOGGING
466            
467             All subclasses of L<Plack::Auth::SSO> use L<Log::Any>
468             to log messages to the category that equals the current
469             package name.
470            
471             =head1 ERRORS
472            
473             Known ORCID errors are stored in the session key auth_sso_error ( L<Plack::Auth::SSO> ).
474            
475             "error" becomes "type"
476            
477             "error_description" becomes "content"
478            
479             Example:
480            
481             {
482             package => "Plack::Auth::SSO::ORCID",
483             package_id => "Plack::Auth::SSO::ORCID",
484             type => "invalid_grant",
485             content => "Reused authorization code: abcdefg"
486            
487             }
488            
489             L<https://members.orcid.org/api/resources/error-codes>
490            
491             when it receives an non json response from ORCID, the type is "unknown", and
492             content is set to the full return value.
493            
494             =head1 AUTHOR
495            
496             Nicolas Franck, C<< <nicolas.franck at ugent.be> >>
497            
498             =head1 SEE ALSO
499            
500             L<Plack::Auth::SSO>
501            
502             =cut
503