Created
February 15, 2021 18:07
-
-
Save rgpower/6bc9d02b50c12427bdf63866788a0b72 to your computer and use it in GitHub Desktop.
Firebase API for Perl w/JWT
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package AnyEvent::Firebase; | |
use common::sense; | |
use AnyEvent; | |
use AnyEvent::HTTP 'http_request'; | |
use Carp 'croak'; | |
use Coro qw(rouse_cb rouse_wait); | |
use Coro::Semaphore; | |
use Crypt::JWT 'encode_jwt'; | |
use JSON::XS; | |
use Try::Tiny; | |
use Types::Serialiser; | |
use URI; | |
use Class::Accessor::Lite ( | |
ro => [ qw( admin_sdk_config web_client_config) ], | |
); | |
use constant { | |
JWT_USER_TOKEN_AUD => 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit', | |
JWT_USER_TOKEN_TTL => 3600, # maximum permitted ttl is 1 hour | |
# oauth endpoint seems to return 3600 regardless of this setting | |
ACCESS_TOKEN_TTL => 3600, # maximum permitted ttl is 1 hour | |
ACCESS_TOKEN_SCOPES => [ | |
'https://www.googleapis.com/auth/firebase.database', | |
'https://www.googleapis.com/auth/userinfo.email', | |
], | |
ACCESS_TOKEN_GRANT_TYPE => 'urn:ietf:params:oauth:grant-type:jwt-bearer', | |
}; | |
my %DB_METHODS = ( | |
DELETE => 1, | |
GET => 1, | |
PATCH => 1, | |
POST => 1, | |
PUT => 1, | |
); | |
sub new { | |
my ($class, %args) = @_; | |
my $self = bless { | |
admin_sdk_config => delete $args{admin_sdk_config}, | |
web_client_config => delete $args{web_client_config}, | |
}, $class; | |
return $self; | |
} | |
sub create_jwt { | |
my ($self, $uid, $claims) = @_; | |
my $admin_sdk_config = $self->admin_sdk_config; | |
my $now_seconds = time(); | |
my $service_account_email = $admin_sdk_config->{client_email}; | |
my $payload = { | |
iss => $service_account_email, | |
sub => $service_account_email, | |
aud => JWT_USER_TOKEN_AUD, | |
iat => $now_seconds, | |
exp => $now_seconds + JWT_USER_TOKEN_TTL, | |
uid => $uid, | |
claims => $claims // {} | |
}; | |
return encode_jwt( | |
payload => $payload, | |
key => \$admin_sdk_config->{private_key}, | |
alg => 'RS256' | |
); | |
} | |
sub request { | |
my ($self, $method, $arg) = @_; | |
my $path = $arg->{path}; | |
my $data = $arg->{data}; | |
my $qparams = $arg->{extra_query_params}; | |
my $headers = $arg->{extra_headers}; | |
my $cv = AE::cv; | |
try { | |
my $web_client_config = $self->web_client_config | |
or croak('no web_client_config'); | |
my $db_url = $web_client_config->{databaseURL} | |
or croak('no databaseURL key in web_client_config'); | |
my $uc_method = uc $method; | |
croak "unsupported method [$method]" unless $DB_METHODS{$uc_method}; | |
my $base = sprintf("%s/%s.json", $db_url, $path); | |
croak "Strange path $base" if $base =~ m!/\.json$!; | |
my $uri = URI->new($base); | |
if (scalar(keys %$qparams)) { | |
$uri->query_form($qparams); | |
} | |
my $auth = $self->auth; | |
my $token_type = $auth->{token_type}; | |
my $access_token = $auth->{access_token}; | |
my $body = encode_json $data if 'HASH' eq ref($data); | |
AE::log debug => "%s %s\n%s\n", $uc_method, $uri->path_query, $body; | |
http_request | |
$uc_method => $uri->as_string, | |
timeout => 30, | |
body => $body, | |
headers => { | |
(scalar(keys %$headers) ? %$headers : ()), | |
# see https://tools.ietf.org/html/rfc6749 (Section 7.1) | |
'Authorization' => sprintf('%s %s', $token_type, $access_token), | |
'Accept' => 'application/json', | |
'Content-Type' => 'application/json', | |
}, | |
sub { | |
$cv->send(@_); | |
}; | |
} catch { | |
$cv->croak($_); | |
}; | |
$cv; | |
} | |
sub auth { | |
my ($self) = @_; | |
state $auth_mutex = Coro::Semaphore->new(1); | |
my $guard = $auth_mutex->guard; | |
my $now = AE::now; | |
my $token_exp = $self->{auth} ? $self->{auth}->{expires_on} : -1; | |
# renew if within 20% of expiry | |
my $must_renew_token = $now > $token_exp; | |
if ($must_renew_token) { | |
$self->_get_access_token(Coro::rouse_cb); | |
my ($body, $hdr) = Coro::rouse_wait; | |
if (200 == $hdr->{Status}) { | |
$self->_update_access_token($body); | |
} else { | |
croak $body; | |
} | |
} | |
return $self->{auth}; | |
} | |
sub _update_access_token { | |
my ($self, $access_token_response) = @_; | |
try { | |
my $auth = decode_json $access_token_response; | |
# set our expire to 90% of ttl in response | |
$auth->{expires_on} = AE::now + int(0.9 * $auth->{expires_in}); | |
$self->{auth} = $auth; | |
my $ts = $auth->{expires_on}; | |
AE::log info => 'updated access_token expires @%d', $auth->{expires_on}; | |
} catch { | |
AE::log error => $_; | |
} | |
} | |
sub _get_access_token { # only for use by backend... | |
my ($self, $cb) = @_; | |
my $admin_sdk_config = $self->admin_sdk_config; | |
my $now_seconds = AE::now; | |
my $service_account_email = $admin_sdk_config->{client_email}; | |
my $token_uri = $admin_sdk_config->{token_uri}; | |
my $private_key_id = $admin_sdk_config->{private_key_id}; | |
my $payload = { | |
aud => $token_uri, | |
exp => $now_seconds + ACCESS_TOKEN_TTL, # Max expire time is one hour | |
iat => $now_seconds, | |
iss => $service_account_email, | |
scope => join(" ", @{(ACCESS_TOKEN_SCOPES)}), | |
claims => { | |
admin => Types::Serialiser::true, | |
} | |
}; | |
my $jwt = encode_jwt( | |
payload => $payload, | |
key => \$admin_sdk_config->{private_key}, | |
alg => 'RS256', | |
extra_headers => { | |
# see https://tools.ietf.org/html/rfc7523 (Section 4) | |
kid => $private_key_id, | |
}, | |
); | |
my $post_uri = URI->new($token_uri); | |
my $query_uri = $post_uri->clone; | |
$query_uri->query_form({ | |
# see https://tools.ietf.org/html/rfc7523 (Section 2.1) | |
grant_type => ACCESS_TOKEN_GRANT_TYPE, | |
assertion => $jwt, | |
}); | |
my $post_body = $query_uri->query; | |
http_request | |
POST => $post_uri->as_string, | |
timeout => 30, | |
body => $post_body, | |
headers => { | |
'accept' => 'application/json', | |
'content-type' => 'application/x-www-form-urlencoded' | |
}, $cb; | |
} | |
1; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment