Skip to content

Instantly share code, notes, and snippets.

@rgpower
Created February 15, 2021 18:07
Show Gist options
  • Save rgpower/6bc9d02b50c12427bdf63866788a0b72 to your computer and use it in GitHub Desktop.
Save rgpower/6bc9d02b50c12427bdf63866788a0b72 to your computer and use it in GitHub Desktop.
Firebase API for Perl w/JWT
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