Mojo-ATProto-OAuth
view release on metacpan or search on metacpan
lib/Mojo/ATProto/OAuth/ResourceClient.pm view on Meta::CPAN
package
Mojo::ATProto::OAuth::ResourceClient;
use Mojo::Base -base, -signatures;
use Mojo::ATProto::OAuth::DPoP qw//;
use Mojo::URL qw//;
use Mojo::UserAgent qw//;
use Mojo::Log qw//;
use Mojo::Promise qw//;
use feature 'try';
use constant DEBUG => $ENV{MOJO_OAUTH_DEBUG} || 0;
our $VERSION = '1.02'; # VERSION
has 'oauth' => sub { die "oauth is required\n" }; # Mojo::ATProto::OAuth instance - only ->store and ->refresh_tokens(_p) are used
has 'ua' => sub($self) { $self->oauth->ua };
has 'log' => sub($self) { $self->oauth->log };
# Sends an authenticated XRPC request against $did's stored session's own
# PDS (host_url), handling DPoP nonce rotation and access-token refresh
# transparently. $method is a lowercase Mojo::UserAgent verb ('get',
# 'post', ...), $path is the XRPC path (and query string, if any) to
# append to host_url, $body (if given) is sent as a JSON request body.
# Returns the decoded JSON response. Dies on a non-2xx response that
# isn't recovered by a nonce/refresh retry - see _request_with_session.
sub request($self, $did, $session_id, $method, $path, $body = undef) {
my $session = $self->oauth->store->get_session($did, $session_id);
return $self->_request_with_session($session, $method, $path, $body, 1, 1);
}
sub request_p($self, $did, $session_id, $method, $path, $body = undef) {
return $self->oauth->store->get_session_p($did, $session_id)->then(sub($session) {
return $self->_request_with_session_p($session, $method, $path, $body, 1, 1);
});
}
# DPoP nonce rotation (RFC 9449): a 401 accompanied by a fresh
# DPoP-Nonce response header means "retry with this nonce", not a real
# auth failure - bounded by $nonce_retries_left. A 401 with no fresh
# nonce means the access token itself needs refreshing, via
# $self->oauth->refresh_tokens(_p) (which persists the refreshed session
# itself) - bounded by $refresh_retries_left. Either bound reaching 0
# means a persistently-failing session dies cleanly instead of looping.
sub _request_with_session($self, $session, $method, $path, $body, $nonce_retries_left, $refresh_retries_left) {
my $url = Mojo::URL->new($session->{host_url})->path($path);
my $dpop_key = Mojo::ATProto::OAuth::DPoP->import_private_pem($session->{dpop_private_key_pem});
my $dpop_jwt = Mojo::ATProto::OAuth::DPoP->proof(
key => $dpop_key, method => $method, url => $url->to_string,
nonce => $session->{dpop_host_nonce}, access_token => $session->{access_token},
issuer => $session->{auth_server_url},
);
$self->log->debug("request: $method $url") if DEBUG;
my $headers = {Authorization => 'DPoP ' . $session->{access_token}, DPoP => $dpop_jwt};
my @extra = defined($body) ? (json => $body) : ();
my $tx = $self->ua->$method($url, $headers, @extra);
my $res = $tx->res;
# The resource server can rotate the DPoP nonce on any response, not
# just a 401 - persist it either way so the next call anywhere starts
# from the freshest known nonce.
my $new_nonce = $res->headers->header('DPoP-Nonce') // '';
if (length($new_nonce) && $new_nonce ne ($session->{dpop_host_nonce} // '')) {
$session->{dpop_host_nonce} = $new_nonce;
$self->oauth->store->save_session($session);
}
if (($res->code // 0) == 401) {
if (length($new_nonce) && $nonce_retries_left > 0) {
$self->log->debug('request: retrying with fresh DPoP-Nonce') if DEBUG;
return $self->_request_with_session($session, $method, $path, $body, $nonce_retries_left - 1, $refresh_retries_left);
}
if ($refresh_retries_left > 0) {
$self->log->debug('request: refreshing access token and retrying') if DEBUG;
$session = $self->oauth->refresh_tokens($session);
return $self->_request_with_session($session, $method, $path, $body, $nonce_retries_left, $refresh_retries_left - 1);
}
die "request failed (HTTP 401): session could not be refreshed\n";
}
die $self->_error_message($tx, $res) unless $res->is_success;
return $res->json;
}
sub _request_with_session_p($self, $session, $method, $path, $body, $nonce_retries_left, $refresh_retries_left) {
my $url = Mojo::URL->new($session->{host_url})->path($path);
my $dpop_key = Mojo::ATProto::OAuth::DPoP->import_private_pem($session->{dpop_private_key_pem});
my $dpop_jwt = Mojo::ATProto::OAuth::DPoP->proof(
key => $dpop_key, method => $method, url => $url->to_string,
nonce => $session->{dpop_host_nonce}, access_token => $session->{access_token},
issuer => $session->{auth_server_url},
);
$self->log->debug("request_p: $method $url") if DEBUG;
my $headers = {Authorization => 'DPoP ' . $session->{access_token}, DPoP => $dpop_jwt};
my @extra = defined($body) ? (json => $body) : ();
my $ua_method = "${method}_p";
return $self->ua->$ua_method($url, $headers, @extra)->then(sub($tx) {
my $res = $tx->res;
my $new_nonce = $res->headers->header('DPoP-Nonce') // '';
my $nonce_saved_p = Mojo::Promise->resolve;
if (length($new_nonce) && $new_nonce ne ($session->{dpop_host_nonce} // '')) {
$session->{dpop_host_nonce} = $new_nonce;
$nonce_saved_p = $self->oauth->store->save_session_p($session);
}
return $nonce_saved_p->then(sub {
if (($res->code // 0) == 401) {
if (length($new_nonce) && $nonce_retries_left > 0) {
$self->log->debug('request_p: retrying with fresh DPoP-Nonce') if DEBUG;
return $self->_request_with_session_p($session, $method, $path, $body, $nonce_retries_left - 1, $refresh_retries_left);
}
if ($refresh_retries_left > 0) {
$self->log->debug('request_p: refreshing access token and retrying') if DEBUG;
return $self->oauth->refresh_tokens_p($session)->then(sub($refreshed) {
return $self->_request_with_session_p($refreshed, $method, $path, $body, $nonce_retries_left, $refresh_retries_left - 1);
});
}
die "request_p failed (HTTP 401): session could not be refreshed\n";
}
die $self->_error_message($tx, $res) unless $res->is_success;
return $res->json;
});
});
}
# Extracts the XRPC response's machine-readable `error` field (e.g.
# 'InvalidSwap') alongside the human-readable `message`, so a caller can
# distinguish error *kinds* (e.g. a swapRecord conflict) without needing
# a blessed exception type - matches this library's "no models" rule.
sub _error_message($self, $tx, $res) {
my $json_body = eval { $res->json };
my $message = (ref($json_body) eq 'HASH' && defined($json_body->{message})) ? $json_body->{message} : ($tx->error->{message} // 'unknown error');
my $xrpc_error = (ref($json_body) eq 'HASH' && defined($json_body->{error})) ? $json_body->{error} : undef;
return 'request failed (HTTP ' . ($res->code // 'connection error')
. (defined($xrpc_error) ? ", xrpc_error=$xrpc_error" : '') . "): $message\n";
}
1;
__END__
=head1 NAME
Mojo::ATProto::OAuth::ResourceClient - authenticated XRPC requests against a session's own PDS
( run in 1.051 second using v1.01-cache-2.11-cpan-8dfa8b56332 )