File Coverage

lib/Plack/Middleware/Validate_Google_IAP_JWT.pm
Criterion Covered Total %
statement 70 91 76.9
branch 13 26 50.0
condition 3 6 50.0
subroutine 18 20 90.0
pod 2 8 25.0
total 106 151 70.2


line stmt bran cond sub pod time code
1             #!/usr/bin/env perl
2             package Plack::Middleware::Validate_Google_IAP_JWT;
3 2     2   704324 use strict;
  2         5  
  2         88  
4 2     2   13 use warnings;
  2         4  
  2         311  
5              
6             our $VERSION = "0.04";
7              
8 2         43 use MOP4Import::Base::CLI_JSON -as_base
9             , [fields =>
10             , [key_url => default => "https://www.gstatic.com/iap/verify/public_key-jwk"]
11             , [want_iss => default => "https://cloud.google.com/iap"],
12             , [want_hd => doc => "expected hosted domain"],
13             , [guest_subpath => doc => "Allow guest access(skip JWT check) for this subpath"]
14             , qw(
15             app
16             _iap_public_key
17             _expires_at
18             )
19             ]
20 2     2   1566 ;
  2         208123  
21              
22 2     2   9864 use parent qw(Plack::Middleware);
  2         9  
  2         19  
23              
24 2     2   12366 use File::Basename;
  2         3  
  2         163  
25 2     2   1432 use Time::Piece;
  2         27696  
  2         15  
26              
27 2     2   975 use URI;
  2         6403  
  2         89  
28 2     2   1608 use HTTP::Tiny;
  2         98992  
  2         127  
29              
30 2     2   1890 use Crypt::JWT ();
  2         131488  
  2         99  
31              
32 2         38 use MOP4Import::PSGIEnv qw(
33             HTTP_X_GOOG_IAP_JWT_ASSERTION
34             psgix.goog_iap_jwt
35             psgix.goog_iap_jwt_aud
36             psgix.goog_iap_jwt_email
37             psgix.goog_iap_jwt_sub
38             psgix.goog_iap_jwt_account
39 2     2   1007 );
  2         24582  
40              
41             use MOP4Import::Types
42 2         29 JWT => [[fields => qw(
43             aud email sub
44             )]],
45             Response => [[fields => qw(
46             success
47             url
48             status
49             reason
50             content
51             headers
52             protocol
53             redirects
54             )]],
55             ResHeaders => [[fields => qw(
56             accept-ranges
57             cache-control
58             content-length
59             content-security-policy
60             content-type
61             cross-origin-opener-policy
62             cross-origin-resource-policy
63             date
64             expires
65             last-modified
66             report-to
67             server
68             vary
69             x-content-type-options
70             x-xss-protection
71             )]]
72 2     2   17246 ;
  2         5  
73              
74             sub call {
75 3     3 1 37262 (my MY $self, my Env $env) = @_;
76              
77 3         20 my $app = $self->app;
78              
79             # Allow fake app
80 3 50       33 if (ref $app eq 'ARRAY') {
81 0         0 my $value = $app;
82             $app = sub {
83 0     0   0 return $value;
84 0         0 };
85             }
86              
87 3 100 66     27 if ($self->{guest_subpath}
88             and substr($env->{PATH_INFO}, 0, length($self->{guest_subpath}))
89             eq $self->{guest_subpath}) {
90 1         6 return $app->($env);
91             }
92              
93 2 100       12 unless ($env->{HTTP_X_GOOG_IAP_JWT_ASSERTION}) {
94 1         12 return [403, [], ["Forbidden (no JWT assertion)\n"]];
95             }
96              
97 1         6 (my JWT $jwt, my $err) = $self->decode_jwt_env_or_error($env);
98 1 50       3 if ($err) {
99 1         5 my ($code, $diag) = $self->parse_jwt_error($err);
100 1         15 return [$code, [], [$diag]];
101             }
102              
103 0         0 $env->{'psgix.goog_iap_jwt'} = $jwt;
104 0         0 $env->{'psgix.goog_iap_jwt_aud'} = $jwt->{aud};
105 0         0 $env->{'psgix.goog_iap_jwt_email'} = $jwt->{email};
106 0         0 $env->{'psgix.goog_iap_jwt_sub'} = $jwt->{sub};
107 0 0       0 if ($self->{want_hd}) {
108 0         0 (my $account = $jwt->{email}) =~ s,@\Q$self->{want_hd}\E\z,,;
109 0         0 $env->{'psgix.goog_iap_jwt_account'} = $account;
110             }
111              
112 0         0 $app->($env)
113             }
114              
115             sub decode_jwt_env_or_error {
116 1     1 0 3 (my MY $self, my Env $env) = @_;
117 1         23 local $@;
118 1         3 my $res = eval {$self->decode_jwt_env($env)};
  1         5  
119 1 50       415 if ($@) {
120 1         5 (undef, $@)
121             } else {
122 0         0 $res;
123             }
124             }
125              
126             sub decode_jwt_env {
127 1     1 0 4 (my MY $self, my Env $env) = @_;
128             Crypt::JWT::decode_jwt(
129             token => $env->{HTTP_X_GOOG_IAP_JWT_ASSERTION},
130             kid_keys => $self->iap_public_key,
131             verify_exp => 1, verify_iat => 1,
132             verify_iss => $self->{want_iss},
133 1 50       6 ($self->{want_hd} ? (verify_hd => $self->{want_hd}) : ()),
134             )
135             }
136              
137             sub parse_jwt_error {
138 1     1 0 3 (my MY $self, my $errmsg) = @_;
139 1 50       4 if ($errmsg =~ /^(JWT: \S+ claim check failed.*?) at/) {
140 0         0 (403, $1);
141             } else {
142 1         3 (400, $errmsg);
143             }
144             }
145              
146             sub iap_public_key {
147 1     1 0 3 (my MY $self) = @_;
148 1 50 33     7 if ($self->{_iap_public_key} and (time + 10) < $self->{_expires_at}) {
149             return $self->{_iap_public_key}
150 0         0 }
151 1         6 my ($ok, $err) = $self->fetch_iap_public_key_with_expires;
152 1 50       5 if ($err) {
153 0         0 Carp::croak "Can't fetch iap public_key: $err";
154             }
155              
156 1         4 ($self->{_iap_public_key}, $self->{_expires_at}) = @$ok;
157              
158 1         11 return $self->{_iap_public_key};
159             }
160              
161             sub fetch_iap_public_key {
162 0     0 1 0 (my MY $self) = @_;
163 0         0 my ($ok, $err) = $self->fetch_iap_public_key_with_expires;
164 0 0       0 if ($err) {
165 0         0 return (undef, $err)
166             } else {
167 0         0 $ok->[0]
168             }
169             }
170              
171             sub fetch_iap_public_key_with_expires {
172 1     1 0 3 (my MY $self) = @_;
173 1         12 my Response $response = HTTP::Tiny->new->request(GET => $self->{key_url});
174 1 50       338376 if ($response->{success}) {
175 1         1405 my $jwt = $self->cli_decode_json($response->{content});
176 1         506 my ResHeaders $headers = $response->{headers};
177 1 50       6 my $expires = $headers->{expires} ? $self->parse_http_date($headers->{expires}) : undef;
178 1         212 [$jwt, $expires];
179             } else {
180             (undef, $response->{reason})
181 0         0 }
182             }
183              
184             sub parse_http_date {
185 1     1 0 13 (my MY $self, my $date) = @_;
186 1         11 Time::Piece->strptime($date, "%a, %d %b %Y %H:%M:%S %Z")->epoch
187             }
188              
189             MY->run(\@ARGV) unless caller;
190             1;
191             __END__