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 )