Mojo-ATProto-OAuth

 view release on metacpan or  search on metacpan

README.md  view on Meta::CPAN

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

README.md  view on Meta::CPAN

### 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);

README.md  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 ["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

README.md  view on Meta::CPAN

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

## LOWER-LEVEL METHODS

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

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

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

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

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

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

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

## THE STORE INTERFACE

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

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


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',

t/par.t  view on Meta::CPAN


    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 = {

t/par.t  view on Meta::CPAN

    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');



( run in 0.897 second using v1.01-cache-2.11-cpan-8dfa8b56332 )