view release on metacpan or search on metacpan
Default scopes requested by ["start\_auth\_flow"](#start_auth_flow) when no per-call `scopes` opt is given. Defaults to `['atproto']`.
Accepts either an arrayref of individual scope strings (`['atproto', 'account:email']`) or a single space-separated string (`'atproto account:email'`) - either form is normalized to the arrayref-of-tokens form internally, and always read back as one....
### private\_key
A [Crypt::PK::ECC](https://metacpan.org/pod/Crypt%3A%3APK%3A%3AECC) private key, for a confidential client. `undef` (the default) for a public client. Must be set together with ["key\_id"](#key_id) - see ["is\_confidential"](#is_confidential).
### key\_id
The key ID matching ["private\_key"](#private_key). See ["is\_confidential"](#is_confidential).
### loopback
Boolean, true for clients constructed via ["new\_localhost"](#new_localhost). Governs whether ["client\_metadata"](#client_metadata) may be called (it dies for a loopback client - there is no document to serve).
### identity
A [Mojo::ATProto::OAuth::Identity](https://metacpan.org/pod/Mojo%3A%3AATProto%3A%3AOAuth%3A%3AIdentity) instance, used to resolve handles and DIDs. Defaults to a fresh instance.
### resolver
### new\_localhost
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
);
## METHODS
### is\_confidential
my $bool = $oauth->is_confidential;
True if both ["private\_key"](#private_key) and ["key\_id"](#key_id) are set.
### client\_metadata
my $doc = $oauth->client_metadata;
Returns the client ID metadata document (see [Mojo::ATProto::OAuth::ClientMetadata](https://metacpan.org/pod/Mojo%3A%3AATProto%3A%3AOAuth%3A%3AClientMetadata)) this client's `client_id` URL must serve byte for byte. Dies if called on a loopback clien...
### start\_auth\_flow
my $redirect_url = $oauth->start_auth_flow(%opts);
session_id => $state, # the PAR 'state' value
host_url => 'https://pds.example.com',
auth_server_url => 'https://auth.example.com',
auth_server_token_endpoint => '...',
auth_server_revocation_endpoint => '...', # or undef
scopes => [ 'atproto', ... ],
access_token => '...',
refresh_token => '...',
dpop_authserver_nonce => '...',
dpop_host_nonce => '...',
dpop_private_key_pem => '...', # PEM, see Mojo::ATProto::OAuth::DPoP
client_state => $opts_client_state, # from start_auth_flow(_p), or undef
extra => $opts_extra, # from start_auth_flow(_p), or undef
}
If this auth request came from ["start\_scope\_upgrade"](#start_scope_upgrade), `session_id` here is the _existing_ session's id (not a new one) and `scopes` is the union of the existing session's scopes and the newly-granted ones - see ["start\_scop...
On success, the now-consumed auth-request row is deleted from ["store"](#store); a failure to delete it is logged and otherwise ignored (the session itself is already safely persisted at that point - a leftover auth-request row is inert, not a correc...
### process\_callback\_p
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
has 'ua' => sub {
my $ua = Mojo::UserAgent->new(request_timeout => 10);
no strict;
$ua->transactor->name('Mojo::ATProto::OAuth/' . $VERSION || 'dev');
use strict;
return $ua;
};
has 'client_id' => sub { die "client_id is required\n" };
has 'callback_url' => sub { die "callback_url is required\n" };
has 'private_key' => undef; # Crypt::PK::ECC, confidential clients only
has 'key_id' => undef;
has 'loopback' => 0; # true for new_localhost() clients - see below
has 'identity' => sub { Mojo::ATProto::OAuth::Identity->new };
has 'resolver' => sub { Mojo::ATProto::OAuth::Resolver->new };
has 'log' => sub { Mojo::Log->new(level => $ENV{MOJO_LOG_LEVEL} || 'info') };
has 'client' => sub($self) { Mojo::ATProto::OAuth::ResourceClient->new(oauth => $self) };
sub is_confidential ($self) {
return defined($self->private_key) && defined($self->key_id);
}
sub store ($self, @value) {
# this is basically straight up setter
if (@value) {
$self->{store} = $value[0];
return $self;
}
my $store = $self->{store};
lib/Mojo/ATProto/OAuth.pm view on Meta::CPAN
);
}
sub client_metadata ($self) {
die "client_metadata: loopback clients don't serve a client metadata document\n" if $self->loopback;
return Mojo::ATProto::OAuth::ClientMetadata->build(
client_id => $self->client_id,
callback_url => $self->callback_url,
scopes => $self->scopes,
($self->is_confidential ? (private_key => $self->private_key, key_id => $self->key_id) : ()),
);
}
# The `client_assertion_type`/`client_assertion` form fields every
# confidential-client request (PAR, token exchange, refresh) needs -
# empty for a public client, so callers can unconditionally merge this
# in rather than each repeating an `if ($self->is_confidential)` guard.
sub _client_assertion_params ($self, $audience) {
return {} unless $self->is_confidential;
return {
client_assertion_type => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion => Mojo::ATProto::OAuth::DPoP->client_assertion(
key => $self->private_key,
key_id => $self->key_id,
client_id => $self->client_id,
audience => $audience,
),
};
}
# Builds the PAR (Pushed Authorization Request) form body, minus the
# DPoP proof (added per-attempt by the caller, since the DPoP nonce can
# change between attempts).
lib/Mojo/ATProto/OAuth.pm view on Meta::CPAN
return ($res, $nonce);
});
};
return $attempt->($args{nonce} // '', 2);
}
# 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};
lib/Mojo/ATProto/OAuth.pm view on Meta::CPAN
return {
state => $state,
auth_server_url => $auth_meta->{issuer},
scopes => $scopes,
pkce_verifier => $pkce_verifier,
request_uri => $par_resp->{request_uri},
auth_server_token_endpoint => $auth_meta->{token_endpoint},
auth_server_revocation_endpoint => $auth_meta->{revocation_endpoint},
dpop_authserver_nonce => $dpop_nonce,
dpop_private_key_pem => Mojo::ATProto::OAuth::DPoP->export_private_pem($dpop_key),
};
}
sub send_auth_request_p($self, $auth_meta, %opts) {
my $scopes = _normalize_scopes($opts{scopes}) // $self->scopes;
my $login_hint = $opts{login_hint};
$self->log->debug("send_auth_request_p: issuer=$auth_meta->{issuer} scopes=[" . join(',', @$scopes) . ']') if DEBUG;
my $par_url = $auth_meta->{pushed_authorization_request_endpoint};
lib/Mojo/ATProto/OAuth.pm view on Meta::CPAN
return {
state => $state,
auth_server_url => $auth_meta->{issuer},
scopes => $scopes,
pkce_verifier => $pkce_verifier,
request_uri => $par_resp->{request_uri},
auth_server_token_endpoint => $auth_meta->{token_endpoint},
auth_server_revocation_endpoint => $auth_meta->{revocation_endpoint},
dpop_authserver_nonce => $dpop_nonce,
dpop_private_key_pem => Mojo::ATProto::OAuth::DPoP->export_private_pem($dpop_key),
};
});
}
# Exchanges an authorization code for tokens. $info is the AuthRequestData-equivalent
# hashref from send_auth_request(_p)/a store lookup - reuses its DPoP
# keypair (RFC 9449 requires the same key for every proof tied to one
# authorization attempt) and PKCE verifier. Returns a TokenResponse-
# equivalent hashref (sub, scope, access_token, refresh_token) plus the
# final dpop_authserver_nonce, for the caller to persist.
lib/Mojo/ATProto/OAuth.pm view on Meta::CPAN
my $body = {
client_id => $self->client_id,
redirect_uri => $self->callback_url,
grant_type => 'authorization_code',
code => $auth_code,
code_verifier => $info->{pkce_verifier},
%{$self->_client_assertion_params($info->{auth_server_url})},
};
my $dpop_key = Mojo::ATProto::OAuth::DPoP->import_private_pem($info->{dpop_private_key_pem});
my ($res, $dpop_nonce) = $self->_post_dpop_retry(
url => $info->{auth_server_token_endpoint}, body => $body, key => $dpop_key,
nonce => $info->{dpop_authserver_nonce}, label => 'initial token request',
);
die "initial token request failed (HTTP " . $res->code . "): " . $self->_parse_auth_error_reason($res) . "\n"
unless $res->code == 200;
my $token_resp = $res->json;
$token_resp->{dpop_authserver_nonce} = $dpop_nonce;
lib/Mojo/ATProto/OAuth.pm view on Meta::CPAN
my $body = {
client_id => $self->client_id,
redirect_uri => $self->callback_url,
grant_type => 'authorization_code',
code => $auth_code,
code_verifier => $info->{pkce_verifier},
%{$self->_client_assertion_params($info->{auth_server_url})},
};
my $dpop_key = Mojo::ATProto::OAuth::DPoP->import_private_pem($info->{dpop_private_key_pem});
return $self->_post_dpop_retry_p(
url => $info->{auth_server_token_endpoint}, body => $body, key => $dpop_key,
nonce => $info->{dpop_authserver_nonce}, label => 'initial token request',
)->then(sub ($res, $dpop_nonce) {
die "initial token request failed (HTTP " . $res->code . "): " . $self->_parse_auth_error_reason($res) . "\n"
unless $res->code == 200;
my $token_resp = $res->json;
$token_resp->{dpop_authserver_nonce} = $dpop_nonce;
$self->log->debug("send_initial_token_request_p: succeeded, sub=" . ($token_resp->{sub} // '?') . " scope=" . ($token_resp->{scope} // '')) if DEBUG;
lib/Mojo/ATProto/OAuth.pm view on Meta::CPAN
session_id => $info->{state},
host_url => $host_url,
auth_server_url => $info->{auth_server_url},
auth_server_token_endpoint => $info->{auth_server_token_endpoint},
auth_server_revocation_endpoint => $info->{auth_server_revocation_endpoint},
scopes => [split(/ /, $token_resp->{scope} // '')],
access_token => $token_resp->{access_token},
refresh_token => $token_resp->{refresh_token},
dpop_authserver_nonce => $token_resp->{dpop_authserver_nonce},
dpop_host_nonce => $token_resp->{dpop_authserver_nonce}, # bootstrap host nonce from authserver
dpop_private_key_pem => $info->{dpop_private_key_pem},
client_state => $info->{client_state},
extra => $info->{extra},
};
}
# If this auth request was a scope upgrade (see start_scope_upgrade(_p)
# below), collapse the just-issued session data onto the *existing*
# session_id it's upgrading - so the customer's browser session is
# undisturbed - and union its scopes with what's already stored, rather
# than narrowing to just this exchange's own granted scope set. A no-op
lib/Mojo/ATProto/OAuth.pm view on Meta::CPAN
die "refresh_tokens: 'store' must be configured\n" unless defined($self->store);
$self->log->debug("refresh_tokens: account_did=$session->{account_did} session_id=$session->{session_id}") if DEBUG;
my $body = {
client_id => $self->client_id,
grant_type => 'refresh_token',
refresh_token => $session->{refresh_token},
%{$self->_client_assertion_params($session->{auth_server_url})},
};
my $dpop_key = Mojo::ATProto::OAuth::DPoP->import_private_pem($session->{dpop_private_key_pem});
my ($res, $dpop_nonce) = $self->_post_dpop_retry(
url => $session->{auth_server_token_endpoint}, body => $body, key => $dpop_key,
nonce => $session->{dpop_authserver_nonce}, label => 'token refresh',
);
die "token refresh failed (HTTP " . $res->code . "): " . $self->_parse_auth_error_reason($res) . "\n"
unless $res->code == 200;
my $token_resp = $res->json;
$session->{access_token} = $token_resp->{access_token};
$session->{refresh_token} = $token_resp->{refresh_token};
lib/Mojo/ATProto/OAuth.pm view on Meta::CPAN
die "refresh_tokens_p: 'store' must be configured\n" unless defined($self->store);
$self->log->debug("refresh_tokens_p: account_did=$session->{account_did} session_id=$session->{session_id}") if DEBUG;
my $body = {
client_id => $self->client_id,
grant_type => 'refresh_token',
refresh_token => $session->{refresh_token},
%{$self->_client_assertion_params($session->{auth_server_url})},
};
my $dpop_key = Mojo::ATProto::OAuth::DPoP->import_private_pem($session->{dpop_private_key_pem});
return $self->_post_dpop_retry_p(
url => $session->{auth_server_token_endpoint}, body => $body, key => $dpop_key,
nonce => $session->{dpop_authserver_nonce}, label => 'token refresh',
)->then(sub ($res, $dpop_nonce) {
die "token refresh failed (HTTP " . $res->code . "): " . $self->_parse_auth_error_reason($res) . "\n"
unless $res->code == 200;
my $token_resp = $res->json;
$session->{access_token} = $token_resp->{access_token};
$session->{refresh_token} = $token_resp->{refresh_token};
lib/Mojo/ATProto/OAuth.pm view on Meta::CPAN
=head2 callback_url
(Required.) The single redirect URI this client uses.
=head2 scopes
Default scopes requested by L</start_auth_flow> when no per-call C<scopes> opt is given. Defaults to C<['atproto']>.
Accepts either an arrayref of individual scope strings (C<['atproto', 'account:email']>) or a single space-separated string (C<'atproto account:email'>) - either form is normalized to the arrayref-of-tokens form internally, and always read back as on...
=head2 private_key
A L<Crypt::PK::ECC> private key, for a confidential client. C<undef> (the default) for a public client. Must be set together with L</key_id> - see L</is_confidential>.
=head2 key_id
The key ID matching L</private_key>. See L</is_confidential>.
=head2 loopback
Boolean, true for clients constructed via L</new_localhost>. Governs whether L</client_metadata> may be called (it dies for a loopback client - there is no document to serve).
=head2 identity
A L<Mojo::ATProto::OAuth::Identity> instance, used to resolve handles and DIDs. Defaults to a fresh instance.
=head2 resolver
lib/Mojo/ATProto/OAuth.pm view on Meta::CPAN
=head2 new_localhost
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
);
=head1 METHODS
=head2 is_confidential
my $bool = $oauth->is_confidential;
True if both L</private_key> and L</key_id> are set.
=head2 client_metadata
my $doc = $oauth->client_metadata;
Returns the client ID metadata document (see L<Mojo::ATProto::OAuth::ClientMetadata>) this client's C<client_id> URL must serve byte for byte. Dies if called on a loopback client (see L</loopback>) - a loopback client's C<client_id> isn't a fetchable...
=head2 start_auth_flow
my $redirect_url = $oauth->start_auth_flow(%opts);
lib/Mojo/ATProto/OAuth.pm view on Meta::CPAN
session_id => $state, # the PAR 'state' value
host_url => 'https://pds.example.com',
auth_server_url => 'https://auth.example.com',
auth_server_token_endpoint => '...',
auth_server_revocation_endpoint => '...', # or undef
scopes => [ 'atproto', ... ],
access_token => '...',
refresh_token => '...',
dpop_authserver_nonce => '...',
dpop_host_nonce => '...',
dpop_private_key_pem => '...', # PEM, see Mojo::ATProto::OAuth::DPoP
client_state => $opts_client_state, # from start_auth_flow(_p), or undef
extra => $opts_extra, # from start_auth_flow(_p), or undef
}
If this auth request came from L</start_scope_upgrade>, C<session_id> here is the I<existing> session's id (not a new one) and C<scopes> is the union of the existing session's scopes and the newly-granted ones - see L</start_scope_upgrade> for why.
On success, the now-consumed auth-request row is deleted from L</store>; a failure to delete it is logged and otherwise ignored (the session itself is already safely persisted at that point - a leftover auth-request row is inert, not a correctness pr...
=head2 process_callback_p
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/ClientMetadata.pm view on Meta::CPAN
use constant DEBUG => $ENV{MOJO_OAUTH_DEBUG} || 0;
my $LOG = Mojo::Log->new;
# Builds the "client ID metadata document" - the JSON document ATProto
# OAuth's client-ID-metadata-document flow requires be served, byte for
# byte, at the client_id URL itself (no separate client registration
# step).
#
# %config keys: client_id, callback_url, scopes (arrayref, must include
# 'atproto'), private_key + key_id (optional Crypt::PK::ECC keypair -
# presence makes this a confidential client; omit both for a public
# client).
sub build ($class, %config) {
my $client_id = $config{client_id} // die "build: 'client_id' required\n";
my $callback_url = $config{callback_url} // die "build: 'callback_url' required\n";
my $scopes = $config{scopes} // die "build: 'scopes' required\n";
my $doc = {
client_id => $client_id,
application_type => 'web',
grant_types => ['authorization_code', 'refresh_token'],
scope => join(' ', @$scopes),
response_types => ['code'],
redirect_uris => [$callback_url],
dpop_bound_access_tokens => true,
token_endpoint_auth_method => 'none',
};
if (defined($config{private_key}) && defined($config{key_id})) {
$doc->{token_endpoint_auth_method} = 'private_key_jwt';
$doc->{token_endpoint_auth_signing_alg} = 'ES256';
$doc->{jwks} = $class->public_jwks(%config);
}
$LOG->debug("ClientMetadata: built document for client_id=$client_id (confidential="
. (defined($config{private_key}) ? 'yes' : 'no') . ')') if DEBUG;
return $doc;
}
# Returns a JWKS document ({keys => [...]}) exposing the client's public
# assertion key - only meaningful for confidential clients. Callable on
# its own (not just via build()), so a public client's missing key
# still gets a safe {keys => []} rather than dying - but build() itself
# only sets this on the *document* for a confidential client; a public
# client's document omits the `jwks` key entirely, it doesn't get an
# empty one.
sub public_jwks ($class, %config) {
return {keys => []} unless defined($config{private_key}) && defined($config{key_id});
my $pub = $config{private_key}->export_key_jwk('public');
my $jwk = decode_json($pub);
$jwk->{kid} = $config{key_id};
return {keys => [$jwk]};
}
1;
lib/Mojo/ATProto/OAuth/Resolver.pm view on Meta::CPAN
die "invalid auth server metadata: response_types_supported must include 'code'\n"
unless $self->_contains($meta->{response_types_supported}, 'code');
die "invalid auth server metadata: grant_types_supported must include 'authorization_code'\n"
unless $self->_contains($meta->{grant_types_supported}, 'authorization_code');
die "invalid auth server metadata: grant_types_supported must include 'refresh_token'\n"
unless $self->_contains($meta->{grant_types_supported}, 'refresh_token');
die "invalid auth server metadata: code_challenge_methods_supported must include 'S256'\n"
unless $self->_contains($meta->{code_challenge_methods_supported}, 'S256');
die "invalid auth server metadata: token_endpoint_auth_methods_supported must include 'none'\n"
unless $self->_contains($meta->{token_endpoint_auth_methods_supported}, 'none');
die "invalid auth server metadata: token_endpoint_auth_methods_supported must include 'private_key_jwt'\n"
unless $self->_contains($meta->{token_endpoint_auth_methods_supported}, 'private_key_jwt');
die "invalid auth server metadata: token_endpoint_auth_signing_alg_values_supported must include 'ES256'\n"
unless $self->_contains($meta->{token_endpoint_auth_signing_alg_values_supported}, 'ES256');
die "invalid auth server metadata: scopes_supported must include 'atproto'\n"
unless $self->_contains($meta->{scopes_supported}, 'atproto');
die "invalid auth server metadata: authorization_response_iss_parameter_supported must be true\n"
unless $meta->{authorization_response_iss_parameter_supported};
die "invalid auth server metadata: require_pushed_authorization_requests must be true\n"
unless $meta->{require_pushed_authorization_requests};
die "invalid auth server metadata: pushed_authorization_request_endpoint is required\n"
unless length($meta->{pushed_authorization_request_endpoint} // '');
lib/Mojo/ATProto/OAuth/ResourceClient.pm view on Meta::CPAN
# 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);
lib/Mojo/ATProto/OAuth/ResourceClient.pm view on Meta::CPAN
}
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";
lib/Mojo/ATProto/OAuth/SessionStore/Pg.pm view on Meta::CPAN
account_did => $info->{account_did},
handle => $info->{handle},
host_url => $info->{host_url},
auth_server_url => $info->{auth_server_url},
auth_server_token_endpoint => $info->{auth_server_token_endpoint},
auth_server_revocation_endpoint => $info->{auth_server_revocation_endpoint},
scopes => $self->_encode_scopes($info->{scopes}),
request_uri => $info->{request_uri},
pkce_verifier => $info->{pkce_verifier},
dpop_authserver_nonce => $info->{dpop_authserver_nonce},
dpop_private_key_pem => $info->{dpop_private_key_pem},
upgrade_session_id => $info->{upgrade_session_id},
client_state => $self->_encode_json($info->{client_state}),
extra => $self->_encode_json($info->{extra}),
};
}
# client_state/extra come back already-decoded to Perl values by
# ->expand (called on the results object in get_auth_request(_p) above) -
# a JSONB column decodes to undef on its own when the stored value is
# SQL NULL, so no manual decode step is needed here.
lib/Mojo/ATProto/OAuth/SessionStore/Pg.pm view on Meta::CPAN
account_did => $row->{account_did},
handle => $row->{handle},
host_url => $row->{host_url},
auth_server_url => $row->{auth_server_url},
auth_server_token_endpoint => $row->{auth_server_token_endpoint},
auth_server_revocation_endpoint => $row->{auth_server_revocation_endpoint},
scopes => $self->_decode_scopes($row->{scopes}),
request_uri => $row->{request_uri},
pkce_verifier => $row->{pkce_verifier},
dpop_authserver_nonce => $row->{dpop_authserver_nonce},
dpop_private_key_pem => $row->{dpop_private_key_pem},
upgrade_session_id => $row->{upgrade_session_id},
client_state => $row->{client_state},
extra => $row->{extra},
};
}
# client_state/extra deliberately omitted - Mojo::ATProto::OAuth never
# reads them back off a persisted session, only off an auth request (see
# oauth-store-interface memory).
sub _row_from_session($self, $session_data) {
lib/Mojo/ATProto/OAuth/SessionStore/Pg.pm view on Meta::CPAN
handle => $session_data->{handle},
host_url => $session_data->{host_url},
auth_server_url => $session_data->{auth_server_url},
auth_server_token_endpoint => $session_data->{auth_server_token_endpoint},
auth_server_revocation_endpoint => $session_data->{auth_server_revocation_endpoint},
scopes => $self->_encode_scopes($session_data->{scopes}),
access_token => $session_data->{access_token},
refresh_token => $session_data->{refresh_token},
dpop_authserver_nonce => $session_data->{dpop_authserver_nonce},
dpop_host_nonce => $session_data->{dpop_host_nonce},
dpop_private_key_pem => $session_data->{dpop_private_key_pem},
};
}
sub _session_from_row($self, $row) {
return {
account_did => $row->{account_did},
session_id => $row->{session_id},
handle => $row->{handle},
host_url => $row->{host_url},
auth_server_url => $row->{auth_server_url},
auth_server_token_endpoint => $row->{auth_server_token_endpoint},
auth_server_revocation_endpoint => $row->{auth_server_revocation_endpoint},
scopes => $self->_decode_scopes($row->{scopes}),
access_token => $row->{access_token},
refresh_token => $row->{refresh_token},
dpop_authserver_nonce => $row->{dpop_authserver_nonce},
dpop_host_nonce => $row->{dpop_host_nonce},
dpop_private_key_pem => $row->{dpop_private_key_pem},
};
}
sub _encode_scopes($self, $scopes) {
return join(' ', @{$scopes // []});
}
sub _decode_scopes($self, $text) {
return [split(/ /, $text // '')];
}
lib/Mojo/ATProto/OAuth/SessionStore/Pg.pm view on Meta::CPAN
account_did TEXT,
handle TEXT,
host_url TEXT,
auth_server_url TEXT NOT NULL,
auth_server_token_endpoint TEXT NOT NULL,
auth_server_revocation_endpoint TEXT,
scopes TEXT NOT NULL,
request_uri TEXT NOT NULL,
pkce_verifier TEXT NOT NULL,
dpop_authserver_nonce TEXT,
dpop_private_key_pem TEXT NOT NULL,
upgrade_session_id TEXT,
client_state JSONB,
extra JSONB
);
CREATE TABLE sessions (
account_did TEXT NOT NULL,
session_id TEXT NOT NULL,
handle TEXT,
host_url TEXT,
auth_server_url TEXT NOT NULL,
auth_server_token_endpoint TEXT NOT NULL,
auth_server_revocation_endpoint TEXT,
scopes TEXT NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT,
dpop_authserver_nonce TEXT,
dpop_host_nonce TEXT,
dpop_private_key_pem TEXT NOT NULL,
PRIMARY KEY (account_did, session_id)
);
-- 1 down
DROP TABLE sessions;
DROP TABLE auth_requests;
lib/Mojo/ATProto/OAuth/SessionStore/SQLite.pm view on Meta::CPAN
account_did => $info->{account_did},
handle => $info->{handle},
host_url => $info->{host_url},
auth_server_url => $info->{auth_server_url},
auth_server_token_endpoint => $info->{auth_server_token_endpoint},
auth_server_revocation_endpoint => $info->{auth_server_revocation_endpoint},
scopes => $self->_encode_scopes($info->{scopes}),
request_uri => $info->{request_uri},
pkce_verifier => $info->{pkce_verifier},
dpop_authserver_nonce => $info->{dpop_authserver_nonce},
dpop_private_key_pem => $info->{dpop_private_key_pem},
upgrade_session_id => $info->{upgrade_session_id},
client_state => $self->_encode_json($info->{client_state}),
extra => $self->_encode_json($info->{extra}),
};
}
sub _auth_request_from_row($self, $row) {
return {
state => $row->{state},
account_did => $row->{account_did},
handle => $row->{handle},
host_url => $row->{host_url},
auth_server_url => $row->{auth_server_url},
auth_server_token_endpoint => $row->{auth_server_token_endpoint},
auth_server_revocation_endpoint => $row->{auth_server_revocation_endpoint},
scopes => $self->_decode_scopes($row->{scopes}),
request_uri => $row->{request_uri},
pkce_verifier => $row->{pkce_verifier},
dpop_authserver_nonce => $row->{dpop_authserver_nonce},
dpop_private_key_pem => $row->{dpop_private_key_pem},
upgrade_session_id => $row->{upgrade_session_id},
client_state => $self->_decode_json($row->{client_state}),
extra => $self->_decode_json($row->{extra}),
};
}
# client_state/extra deliberately omitted - Mojo::ATProto::OAuth never
# reads them back off a persisted session, only off an auth request (see
# oauth-store-interface memory).
sub _row_from_session($self, $session_data) {
lib/Mojo/ATProto/OAuth/SessionStore/SQLite.pm view on Meta::CPAN
handle => $session_data->{handle},
host_url => $session_data->{host_url},
auth_server_url => $session_data->{auth_server_url},
auth_server_token_endpoint => $session_data->{auth_server_token_endpoint},
auth_server_revocation_endpoint => $session_data->{auth_server_revocation_endpoint},
scopes => $self->_encode_scopes($session_data->{scopes}),
access_token => $session_data->{access_token},
refresh_token => $session_data->{refresh_token},
dpop_authserver_nonce => $session_data->{dpop_authserver_nonce},
dpop_host_nonce => $session_data->{dpop_host_nonce},
dpop_private_key_pem => $session_data->{dpop_private_key_pem},
};
}
sub _session_from_row($self, $row) {
return {
account_did => $row->{account_did},
session_id => $row->{session_id},
handle => $row->{handle},
host_url => $row->{host_url},
auth_server_url => $row->{auth_server_url},
auth_server_token_endpoint => $row->{auth_server_token_endpoint},
auth_server_revocation_endpoint => $row->{auth_server_revocation_endpoint},
scopes => $self->_decode_scopes($row->{scopes}),
access_token => $row->{access_token},
refresh_token => $row->{refresh_token},
dpop_authserver_nonce => $row->{dpop_authserver_nonce},
dpop_host_nonce => $row->{dpop_host_nonce},
dpop_private_key_pem => $row->{dpop_private_key_pem},
};
}
sub _encode_scopes($self, $scopes) {
return join(' ', @{$scopes // []});
}
sub _decode_scopes($self, $text) {
return [split(/ /, $text // '')];
}
lib/Mojo/ATProto/OAuth/SessionStore/SQLite.pm view on Meta::CPAN
account_did TEXT,
handle TEXT,
host_url TEXT,
auth_server_url TEXT NOT NULL,
auth_server_token_endpoint TEXT NOT NULL,
auth_server_revocation_endpoint TEXT,
scopes TEXT NOT NULL,
request_uri TEXT NOT NULL,
pkce_verifier TEXT NOT NULL,
dpop_authserver_nonce TEXT,
dpop_private_key_pem TEXT NOT NULL,
upgrade_session_id TEXT,
client_state TEXT,
extra TEXT
);
CREATE TABLE sessions (
account_did TEXT NOT NULL,
session_id TEXT NOT NULL,
handle TEXT,
host_url TEXT,
auth_server_url TEXT NOT NULL,
auth_server_token_endpoint TEXT NOT NULL,
auth_server_revocation_endpoint TEXT,
scopes TEXT NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT,
dpop_authserver_nonce TEXT,
dpop_host_nonce TEXT,
dpop_private_key_pem TEXT NOT NULL,
PRIMARY KEY (account_did, session_id)
);
-- 1 down
DROP TABLE sessions;
DROP TABLE auth_requests;
t/client_metadata.t view on Meta::CPAN
is($doc->{grant_types}, ['authorization_code', 'refresh_token']);
is($doc->{response_types}, ['code']);
};
subtest 'confidential client metadata document' => sub {
my $key = Mojo::ATProto::OAuth::DPoP->generate_keypair;
my $doc = Mojo::ATProto::OAuth::ClientMetadata->build(
client_id => 'https://pib.example.com/oauth/client-metadata.json',
callback_url => 'https://pib.example.com/oauth/callback',
scopes => ['atproto'],
private_key => $key,
key_id => 'key-1',
);
is($doc->{token_endpoint_auth_method}, 'private_key_jwt');
is($doc->{token_endpoint_auth_signing_alg}, 'ES256');
is(scalar(@{$doc->{jwks}{keys}}), 1, 'one public key exposed');
is($doc->{jwks}{keys}[0]{kid}, 'key-1');
ok(!exists($doc->{jwks}{keys}[0]{d}), 'jwks never leaks the private scalar');
};
subtest 'public_jwks on a public client is empty' => sub {
my $jwks = Mojo::ATProto::OAuth::ClientMetadata->public_jwks(
client_id => 'https://pib.example.com/oauth/client-metadata.json',
callback_url => 'https://pib.example.com/oauth/callback',
my $info = $oauth->send_auth_request($auth_meta, login_hint => 'alice.example.com');
is($info->{request_uri}, 'urn:ietf:params:oauth:request_uri:req-123');
is($info->{auth_server_url}, 'https://auth.example.com');
is($info->{auth_server_token_endpoint}, 'https://auth.example.com/token');
is($info->{scopes}, ['atproto']);
ok(length($info->{state}), 'state generated');
ok(length($info->{pkce_verifier}), 'pkce verifier generated');
ok(length($info->{dpop_authserver_nonce}), 'final DPoP nonce captured');
like($info->{dpop_private_key_pem}, qr/BEGIN (EC )?PRIVATE KEY/, 'DPoP private key persisted as PEM');
};
subtest 'send_auth_request_p (async) behaves the same as the sync path' => sub {
my $oauth = Mojo::ATProto::OAuth->new(
client_id => 'https://pib.example.com/oauth/client-metadata.json',
callback_url => 'https://pib.example.com/oauth/callback',
scopes => ['atproto'],
ua => mock_par_ua(),
);
my $auth_meta = {
is($info->{request_uri}, 'urn:ietf:params:oauth:request_uri:req-123');
ok(length($info->{dpop_authserver_nonce}), 'final DPoP nonce captured');
};
subtest 'confidential client includes a client_assertion in the PAR body' => sub {
my $key = Mojo::ATProto::OAuth::DPoP->generate_keypair;
my $oauth = Mojo::ATProto::OAuth->new(
client_id => 'https://pib.example.com/oauth/client-metadata.json',
callback_url => 'https://pib.example.com/oauth/callback',
scopes => ['atproto'],
private_key => $key,
key_id => 'key-1',
ua => mock_par_ua(1),
);
ok($oauth->is_confidential, 'client reports itself confidential once a key is set');
my $auth_meta = {
issuer => 'https://auth.example.com',
token_endpoint => 'https://auth.example.com/token',
pushed_authorization_request_endpoint => '/par',
};
t/refresh_and_scope_upgrade.t view on Meta::CPAN
my ($ua, $get_code) = mock_auth_server('did:plc:testuser');
my $oauth = make_oauth($ua);
$oauth->start_auth_flow(identifier => 'alice.example.com');
my $state = $store->last_state_for_issuer('https://auth.example.com');
my $session = $oauth->process_callback({state => $state, iss => 'https://auth.example.com', code => $get_code->()});
my $old_access = $session->{access_token};
my $old_refresh = $session->{refresh_token};
my $old_key_pem = $session->{dpop_private_key_pem};
my $refreshed = $oauth->refresh_tokens({%$session});
isnt($refreshed->{access_token}, $old_access, 'access token changed');
isnt($refreshed->{refresh_token}, $old_refresh, 'refresh token changed');
is($refreshed->{dpop_private_key_pem}, $old_key_pem, 'the DPoP key itself is never rotated on refresh');
my $stored = $store->get_session('did:plc:testuser', $state);
is($stored->{access_token}, $refreshed->{access_token}, 'refreshed tokens actually persisted');
$store->delete_session('did:plc:testuser', $state);
};
subtest 'refresh_tokens_p (async)' => sub {
no warnings 'redefine';
local *Mojo::ATProto::OAuth::Identity::lookup = sub ($self, $identifier) { return $stub_identity };
t/resolver.t view on Meta::CPAN
my $server_url = 'https://pds.example.com';
sub valid_metadata {
return {
issuer => 'https://pds.example.com',
authorization_endpoint => 'https://pds.example.com/oauth/authorize',
token_endpoint => 'https://pds.example.com/oauth/token',
response_types_supported => ['code'],
grant_types_supported => ['authorization_code', 'refresh_token'],
code_challenge_methods_supported => ['S256'],
token_endpoint_auth_methods_supported => ['none', 'private_key_jwt'],
token_endpoint_auth_signing_alg_values_supported => ['ES256'],
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,
};
}
t/resolver.t view on Meta::CPAN
};
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();
t/resource_client.t view on Meta::CPAN
# t/flow.t and t/par.t use for their own endpoint URLs, and for
# the same reason (no real DNS/socket in this dev environment).
host_url => '',
auth_server_url => 'https://auth.example.com',
auth_server_token_endpoint => 'https://auth.example.com/token',
scopes => ['atproto'],
access_token => 'access-1',
refresh_token => 'refresh-1',
dpop_authserver_nonce => 'authserver-nonce-1',
dpop_host_nonce => 'host-nonce-1',
dpop_private_key_pem => Mojo::ATProto::OAuth::DPoP->export_private_pem($dpop_key),
%overrides,
};
$store->save_session($session);
return $session;
}
sub make_oauth ($store, $ua) {
return Mojo::ATProto::OAuth->new(
client_id => 'https://pib.example.com/oauth/client-metadata.json',
callback_url => 'https://pib.example.com/oauth/callback',
t/session_store_pg.t view on Meta::CPAN
account_did => 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa',
handle => 'alice.example',
host_url => 'https://pds.example.com',
auth_server_url => 'https://auth.example.com',
auth_server_token_endpoint => 'https://auth.example.com/token',
auth_server_revocation_endpoint => 'https://auth.example.com/revoke',
scopes => ['atproto', 'transition:generic'],
request_uri => 'urn:ietf:params:oauth:request_uri:abc',
pkce_verifier => 'verifier-abc',
dpop_authserver_nonce => 'nonce-1',
dpop_private_key_pem => 'PEM-DATA-1',
upgrade_session_id => undef,
client_state => {csrf => 'xyz'},
extra => undef,
};
}
sub session_fixture {
return {
account_did => 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa',
session_id => 'state-1',
handle => 'alice.example',
host_url => 'https://pds.example.com',
auth_server_url => 'https://auth.example.com',
auth_server_token_endpoint => 'https://auth.example.com/token',
auth_server_revocation_endpoint => 'https://auth.example.com/revoke',
scopes => ['atproto', 'transition:generic'],
access_token => 'tok-1',
refresh_token => 'ref-1',
dpop_authserver_nonce => 'nonce-2',
dpop_host_nonce => 'nonce-3',
dpop_private_key_pem => 'PEM-DATA-1',
};
}
# Fresh store per subtest, migrated down to empty and back up so each
# subtest starts from a clean pair of tables against the same database.
sub fresh_store {
my $store = Mojo::ATProto::OAuth::SessionStore::Pg->new($ENV{TEST_ONLINE});
$store->pg->migrations->migrate(0)->migrate;
return $store;
}
t/session_store_sqlite.t view on Meta::CPAN
account_did => 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa',
handle => 'alice.example',
host_url => 'https://pds.example.com',
auth_server_url => 'https://auth.example.com',
auth_server_token_endpoint => 'https://auth.example.com/token',
auth_server_revocation_endpoint => 'https://auth.example.com/revoke',
scopes => ['atproto', 'transition:generic'],
request_uri => 'urn:ietf:params:oauth:request_uri:abc',
pkce_verifier => 'verifier-abc',
dpop_authserver_nonce => 'nonce-1',
dpop_private_key_pem => 'PEM-DATA-1',
upgrade_session_id => undef,
client_state => {csrf => 'xyz'},
extra => undef,
};
}
sub session_fixture {
return {
account_did => 'did:plc:aaaaaaaaaaaaaaaaaaaaaaaa',
session_id => 'state-1',
handle => 'alice.example',
host_url => 'https://pds.example.com',
auth_server_url => 'https://auth.example.com',
auth_server_token_endpoint => 'https://auth.example.com/token',
auth_server_revocation_endpoint => 'https://auth.example.com/revoke',
scopes => ['atproto', 'transition:generic'],
access_token => 'tok-1',
refresh_token => 'ref-1',
dpop_authserver_nonce => 'nonce-2',
dpop_host_nonce => 'nonce-3',
dpop_private_key_pem => 'PEM-DATA-1',
};
}
subtest 'auth request round-trip' => sub {
my $store = Mojo::ATProto::OAuth::SessionStore::SQLite->new;
my $info = auth_request_fixture();
$store->save_auth_request($info);
is($store->get_auth_request('state-1'), $info, 'round-trips unchanged, including client_state and a NULL extra');