Catalyst-Plugin-OpenIDConnect

 view release on metacpan or  search on metacpan

lib/Catalyst/Plugin/OpenIDConnect/Utils/Store/Redis.pm  view on Meta::CPAN

    my $data = encode_json({
        client_id             => $client_id,
        user                  => $user,
        scope                 => $scope,
        redirect_uri          => $redirect_uri,
        nonce                 => $nonce,
        created_at            => $now,
        expires_at            => $now + $self->code_ttl,
        ( $pkce ? (
            code_challenge        => $pkce->{code_challenge},
            code_challenge_method => $pkce->{code_challenge_method},
        ) : () ),
    });

    $self->_redis->setex( $self->prefix . $code, $self->code_ttl, $data );

    $self->logger->debug(
        "Authorization code created: $code (TTL=" . $self->code_ttl . "s)")
        if $self->logger;

    return $code;
}

=head2 get_authorization_code($code)

Retrieves authorization code data from Redis.

Returns a hashref with the code data, or C<undef> if the code does not exist
or has already expired (Redis TTL handles expiry automatically).

=cut

sub get_authorization_code {
    my ( $self, $code ) = @_;

    $self->logger->debug("Retrieving authorization code: $code") if $self->logger;

    my $raw = $self->_redis->get( $self->prefix . $code );
    return unless defined $raw;

    my $data = try {
        decode_json($raw);
    }
    catch {
        $self->logger->warn("Failed to decode authorization code data: $_")
            if $self->logger;
        undef;
    };

    $self->logger->debug("Authorization code found: $code") if $self->logger && $data;
    return $data;
}

=head2 consume_authorization_code($code)

Atomically fetches and deletes the authorization code from Redis using the
C<GETDEL> command (Redis E<ge> 6.2).  Because C<GETDEL> is a single server-side
operation it is race-free: a second concurrent request carrying the same code
will receive C<nil> from Redis and be rejected.

Returns the decoded code data hashref on success, or C<undef> if the code
does not exist, has already been consumed, or cannot be decoded.

=cut

sub consume_authorization_code {
    my ( $self, $code ) = @_;

    $self->logger->debug("Consuming authorization code: $code") if $self->logger;

    # GETDEL (Redis >= 6.2) fetches and deletes atomically in a single
    # round-trip, eliminating the GET + DEL race condition (HIGH-4).
    my $raw = $self->_redis->getdel( $self->prefix . $code );
    return unless defined $raw;

    my $data = try {
        decode_json($raw);
    }
    catch {
        $self->logger->warn("Failed to decode consumed authorization code data: $_")
            if $self->logger;
        undef;
    };

    $self->logger->debug("Authorization code consumed: $code") if $self->logger && $data;
    return $data;
}

=head2 store_refresh_token($jti, $sub, $client_id, $ttl)

Stores a refresh token JTI in Redis with C<SETEX> using C<$ttl> seconds.  Also
maintains a secondary per-subject Set (C<{prefix}rt_sub:{sub}>) so that all
tokens for a user can be revoked atomically at logout time.

=cut

sub store_refresh_token {
    my ( $self, $jti, $sub, $client_id, $ttl ) = @_;
    my $data = encode_json({ sub => $sub, client_id => $client_id });
    $self->_redis->setex( $self->prefix . 'rt:' . $jti, $ttl, $data );
    # Secondary index for bulk-revocation at logout.
    my $set_key = $self->prefix . 'rt_sub:' . $sub;
    $self->_redis->sadd( $set_key, $jti );
    $self->_redis->expire( $set_key, $ttl );
}

=head2 consume_refresh_token($jti)

Atomically fetches and deletes the JTI entry using C<GETDEL> (Redis E<ge> 6.2).
Returns the decoded data hashref, or C<undef> if absent (already used, revoked,
or expired).

=cut

sub consume_refresh_token {
    my ( $self, $jti ) = @_;
    my $raw = $self->_redis->getdel( $self->prefix . 'rt:' . $jti );
    return unless defined $raw;
    my $data = try { decode_json($raw) } catch { undef };
    if ($data) {
        $self->_redis->srem( $self->prefix . 'rt_sub:' . $data->{sub}, $jti );
    }
    return $data;
}

=head2 revoke_refresh_tokens_for_user($sub)

Revokes all outstanding refresh tokens for the given subject by iterating the
per-subject Redis Set and deleting each JTI key, then deleting the Set itself.
Called at logout time.

=cut

sub revoke_refresh_tokens_for_user {
    my ( $self, $sub ) = @_;
    my $set_key = $self->prefix . 'rt_sub:' . $sub;
    my @jtis    = $self->_redis->smembers($set_key);
    for my $jti (@jtis) {
        $self->_redis->del( $self->prefix . 'rt:' . $jti );
    }
    $self->_redis->del($set_key);
}

# Generate a cryptographically secure random string for authorization codes.
# Uses Bytes::Random::Secure which reads from the OS CSPRNG. The lazy _redis
# attribute means the connection is made after fork(), so random state is
# not shared between worker processes.
sub _generate_secure_random {
    my $bytes   = random_bytes(120);
    my $encoded = encode_base64url($bytes);
    $encoded    =~ s/[^a-zA-Z0-9]//g;
    return substr( $encoded, 0, 128 );
}

__PACKAGE__->meta->make_immutable;
1;

=head1 DEPENDENCIES

L<Redis::Fast> (preferred) or L<Redis>, plus L<JSON::MaybeXS> and
L<Bytes::Random::Secure>.

=head1 AUTHOR

Tim F. Rayner

=head1 LICENSE

This library is free software; you can redistribute it and/or modify
it under the terms of The Artistic License 2.0.



( run in 0.898 second using v1.01-cache-2.11-cpan-995e09ba956 )