Mojo-ATProto-OAuth

 view release on metacpan or  search on metacpan

README.md  view on Meta::CPAN

    my $oauth = Mojo::ATProto::OAuth->new_localhost(
        callback_url      => $callback_url,    # required
        scopes            => \@scopes,         # optional, default ['atproto']
        ua                => $ua,              # optional
        user_agent_header => $header,          # optional
        store             => $store,           # optional
    );

Builds a client using ATProto OAuth's "loopback client" allowance for local-dev testing (ported from indigo's `NewLocalhostConfig`): rather than a real `https://` `client_id` URL serving a fetched metadata document, `client_id` is the fixed sentinel ...

The `client_id` host is the literal string `localhost` - a fixed spec sentinel, not a real address to resolve. That is a separate thing from `callback_url`, which must actually point at `127.0.0.1` (not `localhost`) for a plain-`http` redirect URI to...

### new

    my $oauth = Mojo::ATProto::OAuth->new(
        client_id         => $client_id,       # required, in the form of https://your-site.com/client-metadata.json or something appropriate
        callback_url      => $callback_url,    # required
        scopes            => \@scopes,         # optional, default ['atproto']
        ua                => $ua,              # optional
        user_agent_header => $header,          # optional
        store             => $store,           # optional

README.md  view on Meta::CPAN

Non-blocking counterpart of ["refresh\_tokens"](#refresh_tokens).

## LOWER-LEVEL METHODS

These are used internally by the high-level methods above, and are also exposed for callers that need finer-grained control (e.g. a caller already holding a persisted auth-request row and only needing the token exchange step). Ordinary use of this mo...

### send\_auth\_request / send\_auth\_request\_p

    my $info = $oauth->send_auth_request($auth_meta, %opts);

Sends the PAR request that kicks off an authorization flow, given already-validated auth-server metadata (as returned by ["resolve\_auth\_server\_metadata" in Mojo::ATProto::OAuth::Resolver](https://metacpan.org/pod/Mojo%3A%3AATProto%3A%3AOAuth%3A%3A...

### send\_initial\_token\_request / send\_initial\_token\_request\_p

    my $token_resp = $oauth->send_initial_token_request($auth_code, $info);

Exchanges an authorization code for tokens. `$info` is the `AuthRequestData`- equivalent hashref from ["send\_auth\_request"](#send_auth_request) or a store lookup - reuses its DPoP keypair (RFC 9449 requires the same key for every proof tied to one ...

## THE STORE INTERFACE

["store"](#store) is semi-duck-typed, a base class exists in [Mojo::ATProto::OAuth::SessionStore](https://metacpan.org/pod/Mojo%3A%3AATProto%3A%3AOAuth%3A%3ASessionStore) that will complain loudly if you subclass it without implementing the proper me...

lib/Mojo/ATProto/OAuth.pm  view on Meta::CPAN

        return 'unknown (exception decoding body)';
    }
}

# Shared DPoP-nonce-retry POST helper (RFC 9449: the first attempt with
# no known nonce is expected to be rejected with a fresh one to retry
# with) - used by both send_auth_request (PAR) and
# send_initial_token_request (token exchange), matching indigo's own
# identical 2-attempt loop in both SendAuthRequest and
# SendInitialTokenRequest. Returns ($res, $final_dpop_nonce); does not
# itself validate the final response status - callers differ on what a
# success response looks like (200 vs. 200/201).
sub _post_dpop_retry($self, %args) {
    my $url   = $args{url};
    my $body  = $args{body};
    my $key   = $args{key};
    my $nonce = $args{nonce} // '';
    my $label = $args{label} // 'request';

    my $res;
    for my $attempt (1 .. 2) {

lib/Mojo/ATProto/OAuth.pm  view on Meta::CPAN

}

# Sends the PAR request that kicks off an authorization flow. Returns an
# AuthRequestData-equivalent hashref: state, auth_server_url, scopes,
# pkce_verifier, request_uri, auth_server_token_endpoint,
# auth_server_revocation_endpoint, dpop_authserver_nonce,
# dpop_private_key_pem - everything a store needs to persist and later
# exchange for tokens via send_initial_token_request(_p) below.
#
# $auth_meta is the hashref Mojo::ATProto::OAuth::Resolver::
# resolve_auth_server_metadata(_p) already validated. Low-level: doesn't
# persist anything or resolve an identity - see start_auth_flow(_p) for
# the full orchestration.
sub send_auth_request($self, $auth_meta, %opts) {
    my $scopes     = _normalize_scopes($opts{scopes}) // $self->scopes;
    my $login_hint = $opts{login_hint};

    $self->log->debug("send_auth_request: issuer=$auth_meta->{issuer} scopes=[" . join(',', @$scopes) . ']') if DEBUG;

    my $par_url        = $auth_meta->{pushed_authorization_request_endpoint};
    my $state          = Mojo::ATProto::OAuth::DPoP->secure_random_base64(16);

lib/Mojo/ATProto/OAuth.pm  view on Meta::CPAN

        $session_data->{session_id} = $info->{upgrade_session_id};
        return $session_data;
    });
}

sub _union_scopes($self, $a, $b) {
    my %union = map { $_ => 1 } (@{$a // []}, @{$b // []});
    return [sort keys %union];
}

sub _validate_callback_params($self, $info, $params) {
    if (length($params->{error} // '')) {
        my $msg = "OAuth request callback error: $params->{error}";
        $msg .= ": $params->{error_description}" if length($params->{error_description} // '');
        die "$msg\n";
    }

    my $authserver_url = $params->{iss}  // '';
    my $auth_code      = $params->{code} // '';
    die "missing required query param\n" unless length($authserver_url) && length($auth_code);
    die "callback iss doesn't match request info\n" unless $info->{auth_server_url} eq $authserver_url;

lib/Mojo/ATProto/OAuth.pm  view on Meta::CPAN

# account identity, persists the resulting session via `store`, and
# returns the ClientSessionData-equivalent hashref. Requires `store` to
# be configured.
sub process_callback($self, $params) {
    die "process_callback: 'store' must be configured\n" unless defined($self->store);

    my $state = $params->{state} // die "missing state query param\n";
    $self->log->debug("process_callback: state=$state iss=" . ($params->{iss} // '?')) if DEBUG;
    my $info  = $self->store->get_auth_request($state);

    my ($authserver_url, $auth_code) = $self->_validate_callback_params($info, $params);
    my $token_resp = $self->send_initial_token_request($auth_code, $info);

    my ($account_did, $handle, $host_url);
    if (length($info->{account_did} // '')) {
        $account_did = $info->{account_did};
        die "token subject didn't match original DID\n" unless $token_resp->{sub} eq $account_did;
        if (length($info->{host_url} // '')) {
            # Already resolved and persisted at start_auth_flow(_p) time
            # (either from the identifier, or handed in pre-resolved) -
            # reusing it avoids a third identity lookup for what's purely

lib/Mojo/ATProto/OAuth.pm  view on Meta::CPAN

    return $session_data;
}

sub process_callback_p($self, $params) {
    die "process_callback_p: 'store' must be configured\n" unless defined($self->store);

    my $state = $params->{state} // return Mojo::Promise->reject("missing state query param\n");
    $self->log->debug("process_callback_p: state=$state iss=" . ($params->{iss} // '?')) if DEBUG;

    return $self->store->get_auth_request_p($state)->then(sub ($info) {
        my ($authserver_url, $auth_code) = $self->_validate_callback_params($info, $params);

        return $self->send_initial_token_request_p($auth_code, $info)->then(sub ($token_resp) {
            if (length($info->{account_did} // '')) {
                my $account_did = $info->{account_did};
                die "token subject didn't match original DID\n" unless $token_resp->{sub} eq $account_did;
                $self->log->debug("process_callback_p: known-DID path, account_did=$account_did") if DEBUG;

                # See process_callback's identical branch for why this is
                # conditional - start_scope_upgrade_p's own auth-request
                # rows don't persist host_url, so they still fall back to

lib/Mojo/ATProto/OAuth.pm  view on Meta::CPAN

    my $oauth = Mojo::ATProto::OAuth->new_localhost(
        callback_url      => $callback_url,    # required
        scopes            => \@scopes,         # optional, default ['atproto']
        ua                => $ua,              # optional
        user_agent_header => $header,          # optional
        store             => $store,           # optional
    );

Builds a client using ATProto OAuth's "loopback client" allowance for local-dev testing (ported from indigo's C<NewLocalhostConfig>): rather than a real C<https://> C<client_id> URL serving a fetched metadata document, C<client_id> is the fixed senti...

The C<client_id> host is the literal string C<localhost> - a fixed spec sentinel, not a real address to resolve. That is a separate thing from C<callback_url>, which must actually point at C<127.0.0.1> (not C<localhost>) for a plain-C<http> redirect ...

=head2 new

    my $oauth = Mojo::ATProto::OAuth->new(
        client_id         => $client_id,       # required, in the form of https://your-site.com/client-metadata.json or something appropriate
        callback_url      => $callback_url,    # required
        scopes            => \@scopes,         # optional, default ['atproto']
        ua                => $ua,              # optional
        user_agent_header => $header,          # optional
        store             => $store,           # optional

lib/Mojo/ATProto/OAuth.pm  view on Meta::CPAN

Non-blocking counterpart of L</refresh_tokens>.

=head1 LOWER-LEVEL METHODS

These are used internally by the high-level methods above, and are also exposed for callers that need finer-grained control (e.g. a caller already holding a persisted auth-request row and only needing the token exchange step). Ordinary use of this mo...

=head2 send_auth_request / send_auth_request_p

    my $info = $oauth->send_auth_request($auth_meta, %opts);

Sends the PAR request that kicks off an authorization flow, given already-validated auth-server metadata (as returned by L<Mojo::ATProto::OAuth::Resolver/resolve_auth_server_metadata>).  C<%opts>: C<scopes> (optional, arrayref or space-separated stri...

=head2 send_initial_token_request / send_initial_token_request_p

    my $token_resp = $oauth->send_initial_token_request($auth_code, $info);

Exchanges an authorization code for tokens. C<$info> is the C<AuthRequestData>- equivalent hashref from L</send_auth_request> or a store lookup - reuses its DPoP keypair (RFC 9449 requires the same key for every proof tied to one authorization attemp...

=head1 THE STORE INTERFACE

L</store> is semi-duck-typed, a base class exists in L<Mojo::ATProto::OAuth::SessionStore> that will complain loudly if you subclass it without implementing the proper methods. If you write your own session store driver, you must implement the follow...

lib/Mojo/ATProto/OAuth/Resolver.pm  view on Meta::CPAN

sub resolve_auth_server_metadata($self, $server_url) {
    $self->log->debug("Resolver: fetching auth-server metadata for $server_url") if DEBUG;
    my $doc_url = $self->_auth_server_metadata_url($server_url);
    my $tx      = $self->ua->get($doc_url);
    my $res     = $tx->result;
    $self->log->debug('Resolver: auth-server metadata response status=' . ($res->code // 'connection error')) if DEBUG;
    die "fetching auth server metadata failed: " . ($res->message // 'connection error') . "\n"
        unless defined($res->code) && $res->code == 200;

    my $meta = $res->json;
    $self->_validate_auth_server_metadata($meta, $server_url);
    $self->log->debug("Resolver: auth-server metadata for $server_url validated ok "
        . "(par_endpoint=" . ($meta->{pushed_authorization_request_endpoint} // '?') . ")") if DEBUG;
    return $meta;
}

sub resolve_auth_server_metadata_p($self, $server_url) {
    $self->log->debug("Resolver: fetching auth-server metadata for $server_url (async)") if DEBUG;
    my $doc_url = $self->_auth_server_metadata_url($server_url);
    return $self->ua->get_p($doc_url)->then(sub($tx) {
        my $res = $tx->result;
        $self->log->debug('Resolver: auth-server metadata response status=' . ($res->code // 'connection error')) if DEBUG;
        die "fetching auth server metadata failed: " . ($res->message // 'connection error') . "\n"
            unless defined($res->code) && $res->code == 200;

        my $meta = $res->json;
        $self->_validate_auth_server_metadata($meta, $server_url);
        $self->log->debug("Resolver: auth-server metadata for $server_url validated ok "
            . "(par_endpoint=" . ($meta->{pushed_authorization_request_endpoint} // '?') . ")") if DEBUG;
        return $meta;
    });
}

sub _auth_server_metadata_url($self, $server_url) {
    my $u = Mojo::URL->new($server_url);
    die "not a valid public host URL: $server_url\n"
        unless $u->scheme eq 'https' && length($u->host // '') && !$u->port;
    return Mojo::URL->new(sprintf('https://%s/.well-known/oauth-authorization-server', $u->host));
}

sub _contains($self, $list, $value) {
    return grep { $_ eq $value } @{$list // []};
}

sub _validate_auth_server_metadata($self, $meta, $server_url) {
    die "invalid auth server metadata: empty issuer\n" unless length($meta->{issuer} // '');

    my $iss = Mojo::URL->new($meta->{issuer});
    die "invalid auth server metadata: issuer URL\n"
        unless $iss->scheme eq 'https'
        && !$iss->port
        && !length($iss->path->to_string)
        && !length($iss->fragment // '')
        && !$iss->query->to_string;

t/resolver.t  view on Meta::CPAN

        scopes_supported                                    => ['atproto', 'transition:email'],
        authorization_response_iss_parameter_supported      => true,
        require_pushed_authorization_requests               => true,
        pushed_authorization_request_endpoint               => 'https://pds.example.com/oauth/par',
        dpop_signing_alg_values_supported                   => ['ES256'],
        client_id_metadata_document_supported               => true,
    };
}

subtest 'a fully valid document passes' => sub {
    ok(lives { $resolver->_validate_auth_server_metadata(valid_metadata(), $server_url) }, 'no exception') or note($@);
};

subtest 'empty issuer is rejected' => sub {
    my $meta = valid_metadata();
    $meta->{issuer} = '';
    like(dies { $resolver->_validate_auth_server_metadata($meta, $server_url) }, qr/empty issuer/, 'rejected');
};

subtest 'issuer must match the server URL fetched from' => sub {
    my $meta = valid_metadata();
    $meta->{issuer} = 'https://someone-elses-server.example.com';
    like(dies { $resolver->_validate_auth_server_metadata($meta, $server_url) }, qr/issuer must match request URL/, 'rejected');
};

subtest 'issuer with a path is rejected' => sub {
    my $meta = valid_metadata();
    $meta->{issuer} = 'https://pds.example.com/some/path';
    like(dies { $resolver->_validate_auth_server_metadata($meta, $server_url) }, qr/issuer URL/, 'rejected');
};

subtest 'non-https authorization_endpoint is rejected' => sub {
    my $meta = valid_metadata();
    $meta->{authorization_endpoint} = 'http://pds.example.com/oauth/authorize';
    like(dies { $resolver->_validate_auth_server_metadata($meta, $server_url) }, qr/authorization_endpoint/, 'rejected');
};

subtest 'response_types_supported must include code' => sub {
    my $meta = valid_metadata();
    $meta->{response_types_supported} = ['token'];
    like(dies { $resolver->_validate_auth_server_metadata($meta, $server_url) }, qr/response_types_supported/, 'rejected');
};

subtest 'grant_types_supported must include authorization_code' => sub {
    my $meta = valid_metadata();
    $meta->{grant_types_supported} = ['refresh_token'];
    like(dies { $resolver->_validate_auth_server_metadata($meta, $server_url) }, qr/authorization_code/, 'rejected');
};

subtest 'grant_types_supported must include refresh_token' => sub {
    my $meta = valid_metadata();
    $meta->{grant_types_supported} = ['authorization_code'];
    like(dies { $resolver->_validate_auth_server_metadata($meta, $server_url) }, qr/refresh_token/, 'rejected');
};

subtest 'code_challenge_methods_supported must include S256' => sub {
    my $meta = valid_metadata();
    $meta->{code_challenge_methods_supported} = ['plain'];
    like(dies { $resolver->_validate_auth_server_metadata($meta, $server_url) }, qr/S256/, 'rejected');
};

subtest 'token_endpoint_auth_methods_supported must include none' => sub {
    my $meta = valid_metadata();
    $meta->{token_endpoint_auth_methods_supported} = ['private_key_jwt'];
    like(dies { $resolver->_validate_auth_server_metadata($meta, $server_url) }, qr/must include 'none'/, 'rejected');
};

subtest 'token_endpoint_auth_methods_supported must include private_key_jwt' => sub {
    my $meta = valid_metadata();
    $meta->{token_endpoint_auth_methods_supported} = ['none'];
    like(dies { $resolver->_validate_auth_server_metadata($meta, $server_url) }, qr/private_key_jwt/, 'rejected');
};

subtest 'token_endpoint_auth_signing_alg_values_supported must include ES256' => sub {
    my $meta = valid_metadata();
    $meta->{token_endpoint_auth_signing_alg_values_supported} = ['RS256'];
    like(dies { $resolver->_validate_auth_server_metadata($meta, $server_url) }, qr/token_endpoint_auth_signing_alg_values_supported/, 'rejected');
};

subtest 'scopes_supported must include atproto' => sub {
    my $meta = valid_metadata();
    $meta->{scopes_supported} = ['transition:email'];
    like(dies { $resolver->_validate_auth_server_metadata($meta, $server_url) }, qr/scopes_supported/, 'rejected');
};

subtest 'authorization_response_iss_parameter_supported must be true' => sub {
    my $meta = valid_metadata();
    $meta->{authorization_response_iss_parameter_supported} = false;
    like(dies { $resolver->_validate_auth_server_metadata($meta, $server_url) }, qr/authorization_response_iss_parameter_supported/, 'rejected');
};

subtest 'require_pushed_authorization_requests must be true' => sub {
    my $meta = valid_metadata();
    $meta->{require_pushed_authorization_requests} = false;
    like(dies { $resolver->_validate_auth_server_metadata($meta, $server_url) }, qr/require_pushed_authorization_requests/, 'rejected');
};

subtest 'pushed_authorization_request_endpoint is required' => sub {
    my $meta = valid_metadata();
    delete $meta->{pushed_authorization_request_endpoint};
    like(dies { $resolver->_validate_auth_server_metadata($meta, $server_url) }, qr/pushed_authorization_request_endpoint/, 'rejected');
};

subtest 'dpop_signing_alg_values_supported must include ES256' => sub {
    my $meta = valid_metadata();
    $meta->{dpop_signing_alg_values_supported} = ['RS256'];
    like(dies { $resolver->_validate_auth_server_metadata($meta, $server_url) }, qr/dpop_signing_alg_values_supported/, 'rejected');
};

subtest 'require_request_uri_registration=false is rejected, but absent is fine' => sub {
    my $meta = valid_metadata();
    $meta->{require_request_uri_registration} = false;
    like(dies { $resolver->_validate_auth_server_metadata($meta, $server_url) }, qr/require_request_uri_registration/, 'false is rejected');

    my $meta2 = valid_metadata();
    $meta2->{require_request_uri_registration} = true;
    ok(lives { $resolver->_validate_auth_server_metadata($meta2, $server_url) }, 'true is fine') or note($@);
};

subtest 'client_id_metadata_document_supported must be true' => sub {
    my $meta = valid_metadata();
    $meta->{client_id_metadata_document_supported} = false;
    like(dies { $resolver->_validate_auth_server_metadata($meta, $server_url) }, qr/client_id_metadata_document_supported/, 'rejected');
};

subtest 'resolve_auth_server_url and resolve_auth_server_metadata reject non-public host URLs up front' => sub {
    like(dies { $resolver->resolve_auth_server_url('http://pds.example.com') }, qr/not a valid public host URL/, 'http rejected');
    like(dies { $resolver->resolve_auth_server_url('https://pds.example.com:8080') }, qr/not a valid public host URL/, 'explicit port rejected');
    like(dies { $resolver->resolve_auth_server_metadata('http://pds.example.com') }, qr/not a valid public host URL/, 'http rejected');
};

done_testing;



( run in 1.987 second using v1.01-cache-2.11-cpan-ad19def0cd9 )