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 )