Mojo-ATProto-OAuth
view release on metacpan or search on metacpan
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
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 )