Mojo-ATProto-OAuth
view release on metacpan or search on metacpan
lib/Mojo/ATProto/OAuth.pm view on Meta::CPAN
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).
sub _par_body ($self, $auth_meta, $state, $code_challenge, $scopes, $login_hint) {
my $body = {
client_id => $self->client_id,
state => $state,
redirect_uri => $self->callback_url,
scope => join(' ', @$scopes),
response_type => 'code',
code_challenge => $code_challenge,
code_challenge_method => 'S256',
%{$self->_client_assertion_params($auth_meta->{issuer})},
};
$body->{login_hint} = $login_hint if length($login_hint // '');
return $body;
}
sub _parse_auth_error_reason ($self, $res) {
try {
my $body = $res->json;
return 'unknown' unless ref($body) eq 'HASH';
return $body->{error} // 'unknown';
} catch($ex) {
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) {
$self->log->debug("$label: attempt $attempt POST $url (nonce=" . (length($nonce) ? 'yes' : 'no') . ')') if DEBUG;
my $dpop_jwt = Mojo::ATProto::OAuth::DPoP->proof(key => $key, method => 'POST', url => $url, nonce => $nonce);
my $tx = $self->ua->post($url => {'DPoP' => $dpop_jwt } => form => $body);
$res = $tx->res;
$self->log->debug("$label: attempt $attempt response status=" . ($res->code // 'connection error')) if DEBUG;
my $new_nonce = $res->headers->header('DPoP-Nonce') // '';
$nonce = $new_nonce if length($new_nonce);
if ($res->code == 400 && length($new_nonce)) {
my $reason = $self->_parse_auth_error_reason($res);
if ($reason eq 'use_dpop_nonce') {
$self->log->debug("$label: retrying with fresh DPoP-Nonce") if DEBUG;
next;
}
die "$label failed (HTTP 400): $reason\n";
}
last;
}
return ($res, $nonce);
}
sub _post_dpop_retry_p($self, %args) {
my $url = $args{url};
my $body = $args{body};
my $key = $args{key};
my $label = $args{label} // 'request';
my $attempt;
$attempt = sub($nonce, $attempts_left) {
$self->log->debug("$label: POST $url (nonce=" . (length($nonce) ? 'yes' : 'no') . ", attempts_left=$attempts_left)") if DEBUG;
my $dpop_jwt = Mojo::ATProto::OAuth::DPoP->proof(key => $key, method => 'POST', url => $url, nonce => $nonce);
return $self->ua->post_p($url => {'DPoP' => $dpop_jwt} => form => $body)->then(sub ($tx) {
my $res = $tx->res;
$self->log->debug("$label: response status=" . ($res->code // 'connection error')) if DEBUG;
my $new_nonce = $res->headers->header('DPoP-Nonce') // '';
$nonce = $new_nonce if length($new_nonce);
if ($res->code == 400 && length($new_nonce) && $attempts_left > 1) {
my $reason = $self->_parse_auth_error_reason($res);
if ($reason eq 'use_dpop_nonce') {
$self->log->debug("$label: retrying with fresh DPoP-Nonce") if DEBUG;
return $attempt->($nonce, $attempts_left - 1);
}
die "$label failed (HTTP 400): $reason\n";
}
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};
$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);
my $pkce_verifier = Mojo::ATProto::OAuth::DPoP->secure_random_base64(48);
my $code_challenge = Mojo::ATProto::OAuth::DPoP->s256_challenge($pkce_verifier);
my $dpop_key = Mojo::ATProto::OAuth::DPoP->generate_keypair;
my $body = $self->_par_body($auth_meta, $state, $code_challenge, $scopes, $login_hint);
my ($res, $dpop_nonce) = $self->_post_dpop_retry(url => $par_url, body => $body, key => $dpop_key, label => 'PAR request');
die "PAR request failed (HTTP " . $res->code . "): " . $self->_parse_auth_error_reason($res) . "\n"
unless $res->code == 200 || $res->code == 201;
my $par_resp = $res->json;
die "PAR response missing request_uri\n" unless length($par_resp->{request_uri} // '');
$self->log->debug("send_auth_request: PAR succeeded, state=$state") if DEBUG;
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};
my $state = Mojo::ATProto::OAuth::DPoP->secure_random_base64(16);
my $pkce_verifier = Mojo::ATProto::OAuth::DPoP->secure_random_base64(48);
my $code_challenge = Mojo::ATProto::OAuth::DPoP->s256_challenge($pkce_verifier);
my $dpop_key = Mojo::ATProto::OAuth::DPoP->generate_keypair;
my $body = $self->_par_body($auth_meta, $state, $code_challenge, $scopes, $login_hint);
return $self->_post_dpop_retry_p(url => $par_url, body => $body, key => $dpop_key, label => 'PAR request')->then(sub ($res, $dpop_nonce) {
die "PAR request failed (HTTP " . $res->code . "): " . $self->_parse_auth_error_reason($res) . "\n"
unless $res->code == 200 || $res->code == 201;
my $par_resp = $res->json;
die "PAR response missing request_uri\n" unless length($par_resp->{request_uri} // '');
$self->log->debug("send_auth_request_p: PAR succeeded, state=$state") 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
# (returns $session_data unchanged) for an ordinary login.
sub _apply_scope_upgrade_merge($self, $info, $session_data) {
return $session_data unless length($info->{upgrade_session_id} // '');
$self->log->debug("_apply_scope_upgrade_merge: landing on existing session_id=$info->{upgrade_session_id}") if DEBUG;
my $existing;
try {
$existing = $self->store->get_session($session_data->{account_did}, $info->{upgrade_session_id});
} catch($ex) {
$existing = undef;
}
$session_data->{scopes} = $self->_union_scopes($existing->{scopes}, $session_data->{scopes}) if $existing;
$session_data->{session_id} = $info->{upgrade_session_id};
return $session_data;
}
sub _apply_scope_upgrade_merge_p($self, $info, $session_data) {
return Mojo::Promise->resolve($session_data) unless length($info->{upgrade_session_id} // '');
$self->log->debug("_apply_scope_upgrade_merge_p: landing on existing session_id=$info->{upgrade_session_id}") if DEBUG;
return $self->store->get_session_p($session_data->{account_did}, $info->{upgrade_session_id})->then(sub ($existing) {
$session_data->{scopes} = $self->_union_scopes($existing->{scopes}, $session_data->{scopes});
$session_data->{session_id} = $info->{upgrade_session_id};
return $session_data;
})->catch(sub ($err) {
# No prior session found under that id (shouldn't normally
# happen - the upgrade flow only ever starts from an existing
# one) - still land under the upgrade target id, just without a
# scope merge to fall back on.
$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;
return ($authserver_url, $auth_code);
}
# High-level helper for starting a new session
# resolves an identity to auth-server metadata, sends the PAR request,
# persists the auth request via `store`, and returns the URL the user
# should be redirected to (browser) to approve the auth flow. Requires
# `store` to be configured.
#
# %opts, exactly one of:
# identifier - an atproto handle/DID, or an https:// auth-server URL
# directly (skips identity resolution entirely until the
# callback, same dual-mode as always).
# did + handle + host_url - all three together, when the caller has
# already resolved identity itself (e.g. to make a
# pre-auth decision - reading a public repo record to
# pick a scope set, say) and there's no reason to pay
# for a second identity->lookup(_p) here just to re-derive
# the same did/handle. Auth-server discovery (host_url ->
# auth_server_url) still always happens regardless of
# which mode is used - that's a separate step from
# identity resolution, not something a caller would
# plausibly have pre-computed.
# Plus, independent of the above:
# scopes (optional, arrayref or space-separated string) - overrides $self->scopes for just
# this call, falls back to the client's configured
# default when omitted.
# client_state (optional hashref) - opaque, never inspected here;
# persisted on the auth request and handed back untouched
# inside process_callback(_p)'s result. Intended for
# things like a post-login redirect target.
# extra (optional hashref) - same opaque-pass-through treatment
# as client_state, but conventionally used for data that
# belongs in the caller's own session metadata once
# login completes (e.g. a pre-auth decision worth
# remembering) rather than being callback-routing data.
# This library draws no distinction between the two
# beyond "two separate opaque slots" - what each is used
# for is entirely up to the caller.
sub start_auth_flow($self, %opts) {
die "start_auth_flow: 'store' must be configured\n" unless defined($self->store);
$opts{scopes} = _normalize_scopes($opts{scopes}) if defined($opts{scopes});
$self->log->debug('start_auth_flow: ' . _describe_start_opts(%opts)) if DEBUG;
my ($did, $handle, $host_url, $auth_server_url) = $self->_resolve_start(%opts);
$self->log->debug('start_auth_flow: resolved did=' . ($did // '(bare auth-server URL)') . " handle=" . ($handle // '?') . " auth_server=$auth_server_url") if DEBUG;
my $auth_meta = $self->resolver->resolve_auth_server_metadata($auth_server_url);
my $info = $self->send_auth_request($auth_meta, login_hint => ($handle // $did // $opts{identifier}), ($opts{scopes} ? (scopes => $opts{scopes}) : ()));
lib/Mojo/ATProto/OAuth.pm view on Meta::CPAN
return Mojo::URL->new($auth_meta->{authorization_endpoint})->query({
client_id => $self->client_id,
request_uri => $info->{request_uri},
})->to_string;
}
# Returns (did, handle, host_url, auth_server_url) - did/handle/host_url
# are undef together for the bare-https://-URL entry mode (identity
# genuinely isn't known yet, resolved later in process_callback(_p)
# instead), populated together in every other mode. See
# start_auth_flow(_p)'s own header for what %opts recognizes.
sub _resolve_start($self, %opts) {
if (defined($opts{did}) && defined($opts{handle}) && defined($opts{host_url})) {
return ($opts{did}, $opts{handle}, $opts{host_url}, $self->resolver->resolve_auth_server_url($opts{host_url}));
}
my $identifier = $opts{identifier} // die "start_auth_flow: 'identifier' or 'did'+'handle'+'host_url' required\n";
return (undef, undef, undef, $identifier) if $identifier =~ m{^https://};
my $identity = $self->identity->lookup($identifier);
my $host_url = $self->identity->pds_endpoint($identity);
die "identity does not link to an atproto host (PDS)\n" unless length($host_url // '');
return ($identity->{did}, $identity->{handle}, $host_url, $self->resolver->resolve_auth_server_url($host_url));
}
sub _resolve_start_p($self, %opts) {
if (defined($opts{did}) && defined($opts{handle}) && defined($opts{host_url})) {
return $self->resolver->resolve_auth_server_url_p($opts{host_url})->then(sub ($auth_server_url) {
return ($opts{did}, $opts{handle}, $opts{host_url}, $auth_server_url);
});
}
my $identifier = $opts{identifier} // return Mojo::Promise->reject("start_auth_flow_p: 'identifier' or 'did'+'handle'+'host_url' required\n");
return Mojo::Promise->resolve(undef, undef, undef, $identifier) if $identifier =~ m{^https://};
return $self->identity->lookup_p($identifier)->then(sub ($identity) {
my $host_url = $self->identity->pds_endpoint($identity);
die "identity does not link to an atproto host (PDS)\n" unless length($host_url // '');
return $self->resolver->resolve_auth_server_url_p($host_url)->then(sub ($auth_server_url) {
return ($identity->{did}, $identity->{handle}, $host_url, $auth_server_url);
});
});
}
# High-level helper for completing the auth flow (indigo's
# ProcessCallback): verifies callback query params ($params, a plain
# hashref of the callback request's query parameters) against the
# persisted auth request, exchanges the code for tokens, verifies the
# 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
# descriptive metadata at this point; the token subject match
# above is what actually re-confirms the account. Not every
# known-DID auth request has this though - start_scope_upgrade
# doesn't set it on its own auth-request rows - hence the
# fallback below, unchanged from before this option existed.
$handle = $info->{handle};
$host_url = $info->{host_url};
} else {
my $identity = $self->identity->lookup($account_did);
$handle = $identity->{handle};
$host_url = $self->identity->pds_endpoint($identity);
}
$self->log->debug("process_callback: known-DID path, account_did=$account_did") if DEBUG;
} else {
$account_did = $token_resp->{sub} // die "token response missing sub\n";
my $identity = $self->identity->lookup($account_did);
$handle = $identity->{handle};
$host_url = $self->identity->pds_endpoint($identity);
my $resolved = $self->resolver->resolve_auth_server_url($host_url);
die "token subject auth server did not match original\n" unless $resolved eq $authserver_url;
$self->log->debug("process_callback: bare-URL entry path, resolved account_did=$account_did") if DEBUG;
}
my $session_data = $self->_build_session_data($info, $token_resp, $account_did, $handle, $host_url);
$session_data = $self->_apply_scope_upgrade_merge($info, $session_data);
$self->store->save_session($session_data);
$self->log->debug("process_callback: session saved, account_did=$account_did session_id=$session_data->{session_id}") if DEBUG;
# Non-fatal on failure to clean up, matching indigo's own
# ProcessCallback (log-and-continue) - the session itself is already
# safely persisted at this point; a leftover auth-request row is
# inert, not a correctness problem.
try {
$self->store->delete_auth_request($state);
} catch($ex) {
$self->log->warn("failed to delete auth request info for state=$state: $ex");
}
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
# a fresh lookup here exactly as before this option existed.
return Mojo::Promise->resolve($info->{handle}, $info->{host_url})
->then(sub ($handle, $host_url) { return $self->_finish_callback_p($state, $info, $token_resp, $account_did, $handle, $host_url) })
if length($info->{host_url} // '');
return $self->identity->lookup_p($account_did)->then(sub ($identity) {
return $self->_finish_callback_p($state, $info, $token_resp, $account_did, $identity->{handle}, $self->identity->pds_endpoint($identity));
});
}
my $account_did = $token_resp->{sub} // die "token response missing sub\n";
return $self->identity->lookup_p($account_did)->then(sub ($identity) {
my $host_url = $self->identity->pds_endpoint($identity);
return $self->resolver->resolve_auth_server_url_p($host_url)->then(sub ($resolved) {
die "token subject auth server did not match original\n" unless $resolved eq $authserver_url;
$self->log->debug("process_callback_p: bare-URL entry path, resolved account_did=$account_did") if DEBUG;
return $self->_finish_callback_p($state, $info, $token_resp, $account_did, $identity->{handle}, $host_url);
});
});
});
});
}
sub _finish_callback_p($self, $state, $info, $token_resp, $account_did, $handle, $host_url) {
my $session_data = $self->_build_session_data($info, $token_resp, $account_did, $handle, $host_url);
return $self->_apply_scope_upgrade_merge_p($info, $session_data)->then(sub ($merged) {
$session_data = $merged;
return $self->store->save_session_p($session_data);
})->then(sub {
$self->log->debug("_finish_callback_p: session saved, account_did=$account_did session_id=$session_data->{session_id}") if DEBUG;
return $self->store->delete_auth_request_p($state)->catch(sub ($err) {
$self->log->warn("failed to delete auth request info for state=$state: $err");
});
})->then(sub { return $session_data });
}
# Starts a scope-upgrade authorization for an already-known, already-
# verified session; seamlessly request a broader scope set without a
# full re-login, merging the result back into the *existing* session
# (see _apply_scope_upgrade_merge(_p) above) rather than replacing it.
# $session is the existing stored session hashref; $additional_scopes is
# what's newly needed. The PAR request actually asks for the union of
# the session's current scopes and these, so a repeated upgrade request
# for the same additional scope is idempotent rather than narrowing what
# gets asked for. Returns the redirect URL, same as start_auth_flow.
sub start_scope_upgrade($self, $session, $additional_scopes) {
die "start_scope_upgrade: 'store' must be configured\n" unless defined($self->store);
my $scopes = $self->_union_scopes($session->{scopes}, $additional_scopes);
lib/Mojo/ATProto/OAuth.pm view on Meta::CPAN
=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
A L<Mojo::ATProto::OAuth::Resolver> instance, used for auth-server discovery and metadata validation. Defaults to a fresh instance.
=head2 store
A session/auth-request persistence backend - required for L</start_auth_flow>, L</process_callback>, L</start_scope_upgrade>, and L</refresh_tokens> (each dies immediately if unset). See L</THE STORE
INTERFACE> below. C<undef> by default.
May be set to either a store instance, or a short class-name string that resolves to one of the built-in session storage drivers, or the full class name of a driver you want to use. A driver *must* implement the methods listed in L<Mojo::ATProto::OAu...
=head2 ua
A L<Mojo::UserAgent> instance used for every HTTP request this module makes. Defaults to a fresh instance with a 10-second request timeout.
=head2 log
A L<Mojo::Log> instance for debug logging (see L</DEBUG LOGGING>). Defaults to a fresh instance at the level named by C<MOJO_LOG_LEVEL> (C<info> if unset).
=head2 client
A L<Mojo::ATProto::OAuth::ResourceClient> instance, for making authenticated XRPC requests against a session's own PDS - see L</AUTHENTICATED RESOURCE-SERVER REQUESTS> below. Defaults to a fresh instance wired to this C<$oauth> object (built lazily o...
=head1 CONSTRUCTORS
=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);
High-level helper for starting a new login; resolves an identity to auth-server metadata, sends the PAR request, persists the auth request via L</store>, and returns the URL the user's browser should be redirected to for approval. Requires L</store> ...
C<%opts> - exactly one of:
=over 4
=item * C<identifier> - an ATProto handle/DID, or an C<https://> auth-server URL directly (this second form skips identity resolution entirely until the callback - the returned session's C<account_did>/ C<handle> stay unresolved until then).
=item * C<did> + C<handle> + C<host_url> (all three together) - when the caller has already resolved identity itself (e.g. to make a pre-auth decision, such as reading a public repo record to pick a scope set) and there's no reason to pay for a secon...
=back
Plus, independent of the above:
=over 4
=item * C<scopes> (optional, arrayref or space-separated string - see L</scopes>) - overrides L</scopes> for just this call; falls back to the client's configured default when omitted.
=item * C<client_state> (optional hashref) - opaque, never inspected by this module; persisted on the auth request and handed back untouched inside L</process_callback>'s result. Intended for things like a post-login redirect target that needs to sur...
=item * C<extra> (optional hashref) - the same opaque pass-through treatment as C<client_state>, but conventionally used by callers for data that belongs in their own session metadata once login completes (e.g. a pre-auth decision worth remembering),...
=back
=head2 start_auth_flow_p
Non-blocking counterpart of L</start_auth_flow>.
=head2 process_callback
lib/Mojo/ATProto/OAuth.pm view on Meta::CPAN
High-level helper for completing the auth flow. C<$params> is a plain hashref of the callback request's query parameters (e.g. C<< $c->req->params->to_hash >> in a Mojolicious route handler). Verifies the callback params against the persisted auth...
A hashref will be returned as follows:
{
account_did => 'did:plc:...',
handle => 'alice.bsky.social', # or undef
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
Non-blocking counterpart of L</process_callback>. Rejects (rather than dying) on failure, with the same messages.
=head2 start_scope_upgrade
my $redirect_url = $oauth->start_scope_upgrade($session, \@additional_scopes);
Starts a scope-upgrade authorization for an already-known, already- verified session; seamlessly request a broader scope set without a full re-login, merging the result back into the I<existing> session (rather than replacing it) once the callback co...
The PAR request actually asks for the union of C<$session>'s current scopes and C<$additional_scopes>, so a repeated upgrade request for the same additional scope is idempotent rather than narrowing what gets asked for. Returns the redirect URL, same...
=head2 start_scope_upgrade_p
Non-blocking counterpart of L</start_scope_upgrade>.
=head2 refresh_tokens
my $refreshed_session = $oauth->refresh_tokens($session);
Uses the session's stored refresh token to mint a new access token, without involving the user. Reuses the session's own DPoP key - RFC 9449 requires the same key for every proof across one authorization's lifetime, so refreshing never generates a ne...
=head2 refresh_tokens_p
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...
=over 4
=item * C<get_auth_request($state)> / C<get_auth_request_p($state)>
=item * C<save_auth_request($info)> / C<save_auth_request_p($info)>
=item * C<delete_auth_request($state)> / C<delete_auth_request_p($state)>
=item * C<get_session($account_did, $session_id)> / C<get_session_p($account_did, $session_id)>
=item * C<save_session($session_data)> / C<save_session_p($session_data)>
=item * C<delete_session($account_did, $session_id)> / C<delete_session_p($account_did, $session_id)>
=back
A store only needs to implement whichever half a given caller actually uses - the synchronous methods if the caller only ever calls this module's synchronous methods (L</start_auth_flow>, L</process_callback>, etc. - see the standalone example in L</...
This distribution ships three session store drivers:
=over 4
=item * L<Mojo::ATProto::OAuth::SessionStore::Memory> - a plain in-process hashref store - sessions and auth requests are lost on process exit; fine for a single-process script or a test suite, not for a real deployment).
=item * L<Mojo::ATProto::OAuth::SessionStore::SQLite> - an SQLite backed session store, requires L<Mojo::SQLite> to be installed.
=item * L<Mojo::ATProto::OAuth::SessionStore::Pg> - a Postgres backed session store, requires L<Mojo::Pg> to be installed.
=back
The Memory store takes no arguments, whereas the SQLite and Pg stores do (connection strings), these can be passed during construction of the OAuth object:
my $pg_backed = Mojo::ATProto::OAuth->new(
client_id => $client_id,
callback_url => $callback_url,
scopes => 'atproto account:email',
store => [ 'Pg' => 'postgresql://user:pass@host:port/dbname' ]
);
my $sqlite_backed = Mojo::ATProto::OAuth->new(
client_id => $client_id,
callback_url => $callback_url,
scopes => 'atproto account:email',
store => [ 'SQLite' => 'file:/tmp/test.db?wal_mode=1' ]
);
=head1 AUTHENTICATED RESOURCE-SERVER REQUESTS
Everything above gets you a persisted session; it doesn't make any calls against the user's own PDS on your behalf. That's what L</client> (a L<Mojo::ATProto::OAuth::ResourceClient> instance) is for - it loads a session from L</store>, signs a DPoP p...
( run in 0.739 second using v1.01-cache-2.11-cpan-ad19def0cd9 )