Cache certs
[scpubgit/stemmaweb.git] / lib / stemmaweb / Authentication / Credential / Google.pm
1 package stemmaweb::Authentication::Credential::Google;
2
3 use Crypt::OpenSSL::X509;
4 use JSON::WebToken;
5 use IO::All;
6 use JSON::MaybeXS;
7 use MIME::Base64;
8 use LWP::Simple qw(get);
9 use Date::Parse qw(str2time);
10
11 use warnings;
12 use strict;
13 use strictures 1;
14
15 =head1 NAME
16
17 stemmaweb::Authentication::Google - JSON Web Token handler for Google tokens.
18
19 =head1 DESCRIPTION
20
21 Retrieves Google's public certificates, and then retrieves the key from the
22 cert using L<Crypt::OpenSSL::X509>. Finally, uses the pubkey to decrypt a
23 Google token using L<JSON::WebToken>.
24
25 =cut
26
27 sub new {
28     my ($class, $config, $app, $realm) = @_;
29     $class = ref $class || $class;
30
31     my $self = {
32         _config => $config,
33         _app    => $app,
34         _realm  => $realm,
35     };
36
37     bless $self, $class;
38 }
39
40 sub authenticate {
41     my ($self, $c, $realm, $authinfo) =@_;
42
43     my $id_token = $authinfo->{id_token};
44     $id_token ||= $c->req->method eq 'GET' ?
45         $c->req->query_params->{id_token} : $c->req->body_params->{id_token};
46
47     if (!$id_token) {
48         Catalyst::Exception->throw("id_token not specified.");
49     }
50
51     my $userinfo = $self->decode($id_token);
52
53     my $sub = $userinfo->{sub};
54     my $openid = $userinfo->{openid_id};
55
56     if (!$sub || !$openid) {
57         Catalyst::Exception->throw(
58             'Could not retrieve sub and openid from token! Is the token
59             correct?'
60         );
61     }
62
63     return $realm->find_user($userinfo, $c);
64 }
65
66 =head1 METHODS
67
68 =head2 retrieve_certs
69
70 Retrieves a pair of JSON-encoded certificates from the given URL (defaults to
71 Google's public cert url), and returns the decoded JSON object.
72
73 =head3 ARGUMENTS
74
75 =over
76
77 =item url
78
79 Optional. Location where certificates are located.
80 Defaults to https://www.googleapis.com/oauth2/v1/certs.
81
82 =back
83
84 =head3 RETURNS
85
86 Decoded JSON object containing certificates.
87
88 =cut
89
90 sub retrieve_certs {
91     my ($self, $url) = @_;
92
93     my $c = $self->{_app};
94     my $cached = 0;
95     my $certs;
96     my $cache;
97
98     $url ||= ( $c->config->{'Authentication::Credential::Google'}->{public_cert_url} || 'https://www.googleapis.com/oauth2/v1/certs' );
99
100     if ( ($c->registered_plugins('Catalyst::Plugin::Cache')) && ($cache = $c->cache) ) {
101         if ($certs = $cache->get('certs')) {
102             $certs = decode_json($certs);
103
104             foreach my $key (keys %$certs) {
105                 my $cert = $certs->{$key};
106                 my $x509 = Crypt::OpenSSL::X509->new_from_string($cert);
107
108                 if ($self->is_cert_expired($x509)) {
109                     $cached = 0;
110                     last;
111                 } else {
112                     $cached = 1;
113                 }
114             }
115         }
116     }
117
118     if (!$cached) {
119         my $certs_encoded = get($url);
120
121         if ($cache) {
122             $cache->set('certs', $certs_encoded);
123         }
124
125         $certs = decode_json($certs_encoded);
126     }
127
128     return $certs;
129 }
130
131 =head2 get_key_from_cert
132
133 Given a pair of certificates $certs (defaults to L</retrieve_certs>),
134 this function returns the public key of the cert identified by $kid.
135
136 =head3 ARGUMENTS
137
138 =over
139
140 =item $kid
141
142 Required. Index of the certificate hash $hash where the cert we want is
143 located.
144
145 =item $certs
146
147 Optional. A (hashref) pair of certificates.
148 It's retrieved using L</retrieve_certs> if not given,
149 or if the pair is expired.
150
151 =back
152
153 =head3 RETURNS
154
155 Public key of certificate.
156
157 =cut
158
159 sub get_key_from_cert {
160     my ($self, $kid, $certs) = @_;
161
162     $certs ||= $self->retrieve_certs;
163     my $cert = $certs->{$kid};
164     my $x509 = Crypt::OpenSSL::X509->new_from_string($cert);
165
166     if ($self->is_cert_expired($x509)) {
167         # If we ended up here, we were given
168         # an old $certs string from the user.
169         # Let's force getting another.
170         return $self->get_key_from_cert($kid);
171     }
172
173     return $x509->pubkey;
174 }
175
176 =head2 is_cert_expired
177
178 Returns if a given L<Crypt::OpenSSL::X509> certificate is expired.
179
180 =cut
181
182 sub is_cert_expired {
183     my ($self, $x509) = @_;
184
185     my $expiry = str2time($x509->notAfter);
186
187     return time > $expiry;
188 }
189
190 =head2 decode
191
192 Returns the decoded information contained in a user's token.
193
194 =head3 ARGUMENTS
195
196 =over
197
198 =item $token
199
200 Required. The user's token from Google+.
201
202 =item $pubkey
203
204 Optional. A public key string with which to decode the token.
205 If not given, the public key will be retrieved from $certs.
206
207 =item $certs
208
209 Optional. A pair of public key certs retrieved from Google.
210 If not given, or if the certificates have expired, a new
211 pair of certificates is retrieved.
212
213 =back
214
215 =head2 RETURNS
216
217 Decoded JSON object from the decrypted token.
218
219 =cut
220
221 sub decode {
222     my ($self, $token, $certs, $pubkey) = @_;
223
224     if (!$pubkey) {
225         my $details = decode_json(
226             MIME::Base64::decode_base64(
227                 substr( $token, 0, CORE::index($token, '.') )
228             )
229         );
230
231         my $kid = $details->{kid};
232         $pubkey = $self->get_key_from_cert($kid, $certs);
233     }
234
235     return JSON::WebToken->decode($token, $pubkey);
236 }
237
238 =head1 AUTHOR
239
240 Errietta Kostala <e.kostala@shadowcat.co.uk>
241
242 =head1 LICENSE
243
244 This library is free software. You can redistribute it and/or modify
245 it under the same terms as Perl itself.
246
247 =cut
248
249 1;