redis-cloud-auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b9f70dde3145f339c34f39691b3dfd41c8e10b9f9168686291ae2eff33f03911
4
+ data.tar.gz: ce98ed3815f8a8874b33464273b8c65af7c696c4a7822a2397144fe509f21f97
5
+ SHA512:
6
+ metadata.gz: b7824bce2e067f56cba7b05015c2f09749b1e4c019f46ae243fb4a2a4635082b981f8a230cc2785a27645d3de413b76e5390ad7059a7b83f43a78ff3cfd8cc4f
7
+ data.tar.gz: '09e1726c98e063ee9ebf1e2bd86fa5b5c2ba0b2570f2315ecceff72a20d44e2a7014606cda7e51bedae2dd6141ad9767ce1428a51da6a5f07219af99f33aa4f1'
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GitLab B.V.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # redis-cloud-auth
2
+
3
+ Authenticate Redis connections with short-lived **cloud identity** tokens
4
+ instead of a static password, from any `redis-client` / `redis-rb` / Sidekiq
5
+ application.
6
+
7
+ Managed Redis services increasingly support token-based authentication tied to a
8
+ cloud IAM identity:
9
+
10
+ - **Azure** Managed Redis / Azure Cache for Redis, with Microsoft Entra
11
+ - **AWS** ElastiCache, with IAM authentication
12
+ - **Google Cloud** Memorystore for Redis Cluster / Valkey, with IAM
13
+
14
+ In each case the Redis `AUTH` password is a short-lived token minted for a
15
+ workload identity (for example via Kubernetes Workload Identity), not a secret
16
+ you store. `redis-cloud-auth` fetches those tokens, uses them to authenticate
17
+ new connections, and transparently re-authenticates long-lived connections
18
+ before the token expires, so you can point a normal Redis client at a managed,
19
+ token-only Redis with a couple of config lines.
20
+
21
+ ## Install
22
+
23
+ ```ruby
24
+ gem "redis-cloud-auth"
25
+ ```
26
+
27
+ Then add the backend gem for the provider(s) you use (they are loaded lazily and
28
+ are not hard dependencies):
29
+
30
+ - `:azure` → `az-identity`
31
+ - `:aws` ElastiCache → `aws-sigv4`
32
+ - `:gcp` → `googleauth`
33
+
34
+ ## Usage
35
+
36
+ ```ruby
37
+ require "redis-client"
38
+ require "redis_cloud_auth"
39
+
40
+ RedisCloudAuth.register! # once at boot (registers the middleware)
41
+ RedisCloudAuth.logger = my_logger # optional; logs re-auth failures
42
+
43
+ descriptor = { provider: :azure, resource: "https://redis.azure.com" }
44
+
45
+ config = RedisClient.config(
46
+ host: "cache.example.com",
47
+ port: 10000,
48
+ ssl: true, # token auth requires TLS
49
+ username: "<identity-id>",
50
+ password: RedisCloudAuth.credential(descriptor), # token at connect time
51
+ custom: { credentials_provider: descriptor } # enables re-auth middleware
52
+ )
53
+
54
+ config.new_client.call("PING")
55
+ ```
56
+
57
+ The **descriptor** is a plain Hash of simple values, so the whole Redis config
58
+ stays marshallable (important for Sidekiq, which `Marshal`s its config). The same
59
+ descriptor goes in both `password` (via `credential`) and
60
+ `custom[:credentials_provider]` (for the middleware).
61
+
62
+ Works the same with `redis-rb` 5+ (which is built on `redis-client`) and Sidekiq
63
+ 7+.
64
+
65
+ ## How it works
66
+
67
+ Two small pieces wire a provider into a connection:
68
+
69
+ - **`RedisCloudAuth.credential(descriptor)`** returns a marshallable callable
70
+ used as the `password`. `redis-client` invokes it on every (re)connect, so
71
+ each new connection authenticates with a current token. It holds only the
72
+ descriptor and resolves the live provider at call time, so it survives
73
+ `Marshal` round-trips.
74
+ - **The re-auth middleware** (`RedisCloudAuth.register!`) re-issues `AUTH` on a
75
+ live connection shortly before its token expires, tracking expiry per
76
+ connection and understanding RESP3 `HELLO ... AUTH` preludes. It is a no-op on
77
+ connections without a `credentials_provider` descriptor.
78
+
79
+ A **provider** is the only cloud-specific part. It is a tiny duck type:
80
+
81
+ ```ruby
82
+ provider.call # => [username, password] (fresh token each call)
83
+ provider.expires_at # => Time, or nil before the first token is fetched
84
+ ```
85
+
86
+ ## Built-in providers
87
+
88
+ | `:provider` | Service | Backend gem | Key descriptor options |
89
+ |---|---|---|---|
90
+ | `:azure` | Azure Managed Redis / Entra | `az-identity` | `:username`, `:resource`, `:environment` |
91
+ | `:elasticache` | AWS ElastiCache IAM | `aws-sigv4` | `:user_id`, `:cache_name`, `:region`, `:credentials` |
92
+ | `:gcp` | GCP Memorystore IAM | `googleauth` | `:username`, `:scope`, `:credentials` |
93
+
94
+ Providers fetch tokens from the ambient workload identity by default (Azure
95
+ Workload Identity, AWS IRSA/Pod Identity, GKE Workload Identity, or the instance
96
+ metadata server), so no key material lives in the descriptor.
97
+
98
+ > All three providers are verified against a live managed Redis: Entra on Azure
99
+ > Managed Redis, IAM on GCP Memorystore for Redis Cluster, and IAM on AWS
100
+ > ElastiCache Serverless.
101
+ >
102
+ > The `:elasticache` provider signs the token with `AWS_ACCESS_KEY_ID` /
103
+ > `AWS_SECRET_ACCESS_KEY` / `AWS_SESSION_TOKEN` from the environment, or with a
104
+ > `:credentials` object you pass in the descriptor. On EC2/EKS, point it at the
105
+ > instance/pod role credentials (for example via the AWS SDK credential chain).
106
+
107
+ ## Custom providers
108
+
109
+ Register any object that responds to `#call` and `#expires_at`:
110
+
111
+ ```ruby
112
+ RedisCloudAuth::Registry.register_provider(:myprovider) do |descriptor|
113
+ MyProvider.new(descriptor)
114
+ end
115
+ ```
116
+
117
+ ## License
118
+
119
+ MIT.
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedisCloudAuth
4
+ module Providers
5
+ # Microsoft Entra provider. Thin adapter over the `az-identity` gem, which
6
+ # already implements the provider interface (#call => [username, token],
7
+ # #expires_at). The username defaults to the managed identity / service
8
+ # principal object (principal) ID from the token's +oid+ claim.
9
+ #
10
+ # Descriptor keys: :username (optional), :resource (for example
11
+ # "https://redis.azure.com"), :environment (optional Azure cloud).
12
+ module Azure
13
+ def self.build(descriptor)
14
+ # Lazily required so az-identity is only pulled in when Azure is used.
15
+ require "az_identity"
16
+
17
+ AzIdentity::RedisCloudAuth.new(
18
+ username: descriptor[:username],
19
+ resource: descriptor[:resource],
20
+ environment: descriptor[:environment]
21
+ )
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedisCloudAuth
4
+ module Providers
5
+ # Amazon ElastiCache IAM provider. Verified live against ElastiCache
6
+ # Serverless (IAM auth + TLS).
7
+ #
8
+ # ElastiCache IAM authentication uses a short-lived "auth token" that is a
9
+ # SigV4-presigned GET URL (service "elasticache") with Action=connect and
10
+ # User=<user-id>, valid ~15 minutes. The token sent as the Redis password is
11
+ # that presigned URL with the scheme stripped; the Redis AUTH username is the
12
+ # ElastiCache user id.
13
+ #
14
+ # Descriptor keys:
15
+ # :user_id ElastiCache IAM-enabled user (also the Redis AUTH username)
16
+ # :cache_name replication group / cluster id (the presign host label)
17
+ # :region AWS region
18
+ # :credentials optional object with #access_key_id/#secret_access_key/
19
+ # #session_token; defaults to the standard AWS env vars
20
+ #
21
+ # Implements the same interface as the Azure provider:
22
+ # #call => [username, token] #expires_at => Time
23
+ class ElastiCache
24
+ TOKEN_TTL = 900 # seconds; ElastiCache IAM tokens are valid for 15 minutes.
25
+ REFRESH_MARGIN = 60
26
+
27
+ def self.build(descriptor)
28
+ new(descriptor)
29
+ end
30
+
31
+ def initialize(descriptor)
32
+ @user_id = descriptor.fetch(:user_id)
33
+ @cache_name = descriptor.fetch(:cache_name)
34
+ @region = descriptor.fetch(:region)
35
+ @credentials = descriptor[:credentials]
36
+ @mutex = Mutex.new
37
+ end
38
+
39
+ def call
40
+ @mutex.synchronize do
41
+ generate! if @token.nil? || @expires_at.nil? || (@expires_at - REFRESH_MARGIN) <= Time.now
42
+ [@user_id, @token]
43
+ end
44
+ end
45
+
46
+ def expires_at
47
+ @mutex.synchronize { @expires_at }
48
+ end
49
+
50
+ private
51
+
52
+ def generate!
53
+ require "aws-sigv4"
54
+ require "cgi"
55
+
56
+ signer = Aws::Sigv4::Signer.new(
57
+ service: "elasticache",
58
+ region: @region,
59
+ credentials: aws_credentials
60
+ )
61
+
62
+ url = signer.presign_url(
63
+ http_method: "GET",
64
+ url: "https://#{@cache_name}/?Action=connect&User=#{CGI.escape(@user_id)}",
65
+ body: "",
66
+ expires_in: TOKEN_TTL
67
+ ).to_s
68
+
69
+ @token = url.sub(%r{\Ahttps?://}, "")
70
+ @expires_at = Time.now + TOKEN_TTL
71
+ end
72
+
73
+ def aws_credentials
74
+ return @credentials if @credentials
75
+
76
+ Aws::Sigv4::Credentials.new(
77
+ access_key_id: ENV.fetch("AWS_ACCESS_KEY_ID"),
78
+ secret_access_key: ENV.fetch("AWS_SECRET_ACCESS_KEY"),
79
+ session_token: ENV.fetch("AWS_SESSION_TOKEN", nil)
80
+ )
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedisCloudAuth
4
+ module Providers
5
+ # Google Cloud provider for Memorystore for Redis Cluster / Valkey IAM
6
+ # authentication. The Redis password is a Google OAuth2 access token; the
7
+ # identity needs the `roles/redis.dbConnectionUser` role.
8
+ #
9
+ # Uses Application Default Credentials (the `googleauth` gem), so it picks up
10
+ # GKE Workload Identity, the GCE/GKE metadata server, or a service-account
11
+ # key automatically, with no key material in the descriptor.
12
+ #
13
+ # Descriptor keys:
14
+ # :username Redis AUTH username (defaults to "default"; Memorystore
15
+ # verifies the token and ignores the username, confirmed live:
16
+ # token-only, "default", and the SA email all authenticate)
17
+ # :scope OAuth scope(s) (defaults to cloud-platform)
18
+ # :credentials optional prebuilt googleauth/Signet credentials object
19
+ #
20
+ # Implements the common interface: #call => [username, token],
21
+ # #expires_at => Time.
22
+ #
23
+ # NOTE: Memorystore keeps an already-authenticated connection alive even
24
+ # after its token expires, so for GCP the connect-time token (TokenCredential)
25
+ # is the load-bearing part; the re-auth middleware is harmless but less
26
+ # critical than on providers that drop the connection at expiry.
27
+ class GCP
28
+ DEFAULT_SCOPE = "https://www.googleapis.com/auth/cloud-platform"
29
+ DEFAULT_USERNAME = "default"
30
+ REFRESH_MARGIN = 60
31
+
32
+ def self.build(descriptor)
33
+ new(descriptor)
34
+ end
35
+
36
+ def initialize(descriptor)
37
+ @username = descriptor[:username] || DEFAULT_USERNAME
38
+ @scope = descriptor[:scope] || DEFAULT_SCOPE
39
+ @credentials = descriptor[:credentials]
40
+ @mutex = Mutex.new
41
+ end
42
+
43
+ def call
44
+ @mutex.synchronize do
45
+ creds = credentials
46
+ creds.fetch_access_token! if creds.access_token.nil? || creds.expires_within?(REFRESH_MARGIN)
47
+ [@username, creds.access_token]
48
+ end
49
+ end
50
+
51
+ def expires_at
52
+ @mutex.synchronize { @credentials&.expires_at }
53
+ end
54
+
55
+ private
56
+
57
+ def credentials
58
+ @credentials ||= begin
59
+ require "googleauth"
60
+ Google::Auth.get_application_default(Array(@scope))
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedisCloudAuth
4
+ # redis-client middleware that proactively re-issues AUTH on a live connection
5
+ # shortly before a rotating access token expires, so pooled connections are
6
+ # refreshed before the server rejects a command. Connect-time authentication
7
+ # is handled by the callable password ({TokenCredential}); this only covers
8
+ # already-established, long-lived connections.
9
+ #
10
+ # The provider caches a single token process-wide and advances its
11
+ # `expires_at` whenever any connection triggers a refresh. Each pooled
12
+ # connection, however, stays authenticated with whatever token it last sent
13
+ # AUTH with. We therefore track expiry per connection (redis-client builds one
14
+ # middleware instance per connection) rather than reading the shared provider
15
+ # expiry, so a connection still holding the old token re-auths instead of
16
+ # being skipped because another connection already refreshed.
17
+ #
18
+ # Register globally via RedisClient.register (see
19
+ # RedisCloudAuth.register!). It is a no-op unless a credentials
20
+ # provider descriptor is present in the connection's `custom` config under
21
+ # :credentials_provider.
22
+ module ReauthMiddleware
23
+ # Re-authenticate at least this many seconds before the token expires.
24
+ # Microsoft recommends sending the new token at least 3 minutes early.
25
+ REAUTHENTICATION_LEEWAY = 180.0
26
+ # Extra random lead time (0..this) added per connection so pooled
27
+ # connections do not all re-authenticate at the same instant.
28
+ REAUTHENTICATION_JITTER = 30.0
29
+
30
+ def call(command, config)
31
+ # Skip when we are issuing our own AUTH below, to avoid recursing.
32
+ reauthenticate(config) unless auth_command?(command)
33
+ super
34
+ end
35
+
36
+ def call_pipelined(commands, config)
37
+ # The connection prelude authenticates itself via the password; when an
38
+ # AUTH is already part of the pipeline, skip re-auth and instead record
39
+ # the expiry of the token this connection just authenticated with.
40
+ if commands.any? { |command| auth_command?(command) }
41
+ result = super
42
+ track_token_expiry(config)
43
+ result
44
+ else
45
+ reauthenticate(config)
46
+ super
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def auth_command?(command)
53
+ name = command.first.to_s
54
+ return true if name.casecmp?("AUTH")
55
+
56
+ # RESP3 connections (for example Sidekiq 7) authenticate in the prelude as
57
+ # `HELLO 3 AUTH <user> <token>`, so treat that as an AUTH too.
58
+ name.casecmp?("HELLO") && command.any? { |arg| arg.to_s.casecmp?("AUTH") }
59
+ end
60
+
61
+ # Per-connection lead time, fixed once so this connection re-authenticates
62
+ # at a stable, slightly randomized point before expiry.
63
+ def reauthentication_leeway
64
+ @reauthentication_leeway ||= REAUTHENTICATION_LEEWAY + (rand * REAUTHENTICATION_JITTER)
65
+ end
66
+
67
+ def reauthenticate(config)
68
+ descriptor = config.custom && config.custom[:credentials_provider]
69
+ return unless descriptor
70
+
71
+ provider = Registry.fetch(descriptor)
72
+
73
+ # Decide from the expiry of the token THIS connection last authed with,
74
+ # falling back to the provider's current expiry before the first AUTH is
75
+ # observed. A provider that exposes no expiry can't tell us when to
76
+ # refresh, so leave that to the reconnect path.
77
+ expires_at = @token_expires_at || provider.expires_at
78
+ return unless expires_at
79
+ return if (expires_at - reauthentication_leeway) > Time.now
80
+
81
+ token = provider.call.last
82
+ return unless token
83
+
84
+ client.call("AUTH", config.username, token)
85
+ @token_expires_at = provider.expires_at
86
+ rescue StandardError => e
87
+ # A transient token-refresh (or AUTH) failure inside the leeway window
88
+ # must not fail the command: the current token is still valid until
89
+ # expiry, so log and let the operation proceed on the existing creds.
90
+ log_reauthentication_failure(e)
91
+ end
92
+
93
+ # Log at most once per leeway window per connection. A sustained token
94
+ # endpoint outage would otherwise re-enter this path and log on every Redis
95
+ # command until the token actually expires.
96
+ def log_reauthentication_failure(error)
97
+ now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
98
+ return if @last_failure_log_at && (now - @last_failure_log_at) < REAUTHENTICATION_LEEWAY
99
+
100
+ @last_failure_log_at = now
101
+ RedisCloudAuth.logger&.warn("Redis token re-authentication failed: #{error.message}")
102
+ end
103
+
104
+ # Remember the expiry of the token this connection authenticated with during
105
+ # the prelude, so subsequent re-auth decisions track this connection rather
106
+ # than the process-shared provider expiry.
107
+ def track_token_expiry(config)
108
+ descriptor = config.custom && config.custom[:credentials_provider]
109
+ return unless descriptor
110
+
111
+ @token_expires_at = Registry.fetch(descriptor).expires_at
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedisCloudAuth
4
+ # Process-global registry of providers, keyed by a marshallable descriptor
5
+ # (a Hash of simple values). Providers are memoized so every pooled
6
+ # connection and thread shares one provider, which caches the access token
7
+ # and refreshes it as needed.
8
+ #
9
+ # The indirection through a descriptor is deliberate: the descriptor (not the
10
+ # live provider) is embedded in the Redis config, so the config stays
11
+ # marshallable when Sidekiq round-trips it through Marshal.dump/load. The
12
+ # provider itself (holds tokens, does I/O) is resolved here at runtime and
13
+ # never serialized.
14
+ #
15
+ # Built-in providers: +:azure+ (Microsoft Entra, via the az-identity gem),
16
+ # +:elasticache+ (Amazon ElastiCache IAM, via aws-sigv4), and +:gcp+ (Google
17
+ # Memorystore IAM, via googleauth). Register more with {register_provider}.
18
+ module Registry
19
+ MUTEX = Mutex.new
20
+
21
+ @providers = {} # descriptor => live provider
22
+ @builders = {} # provider name (Symbol) => ->(descriptor) { provider }
23
+
24
+ class << self
25
+ # Returns the memoized provider for +descriptor+, building it on first
26
+ # use. The middleware calls this on every command on a token-authed
27
+ # connection, so the cache-hit path is lock-free; only the first build
28
+ # per descriptor takes the mutex.
29
+ def fetch(descriptor)
30
+ @providers[descriptor] || MUTEX.synchronize { @providers[descriptor] ||= build(descriptor) }
31
+ end
32
+
33
+ # Register a builder for a provider name. The block receives the
34
+ # descriptor Hash and must return an object responding to #call
35
+ # (=> [username, password]) and #expires_at (=> Time or nil).
36
+ def register_provider(name, &builder)
37
+ MUTEX.synchronize { @builders[name.to_sym] = builder }
38
+ end
39
+
40
+ # Inject a prebuilt provider for a descriptor. Intended for tests.
41
+ def store(descriptor, provider)
42
+ MUTEX.synchronize { @providers[descriptor] = provider }
43
+ end
44
+
45
+ def clear!
46
+ MUTEX.synchronize { @providers = {} }
47
+ end
48
+
49
+ private
50
+
51
+ def build(descriptor)
52
+ name = descriptor[:provider].to_s.to_sym
53
+ builder = @builders[name] || default_builder(name)
54
+ raise Error, "unknown Redis credentials provider: #{descriptor[:provider].inspect}" unless builder
55
+
56
+ builder.call(descriptor)
57
+ end
58
+
59
+ # Built-in providers are required lazily so their (optional) backing gems
60
+ # are only loaded when actually used.
61
+ def default_builder(name)
62
+ case name
63
+ when :azure
64
+ lambda do |descriptor|
65
+ require_relative "providers/azure"
66
+ Providers::Azure.build(descriptor)
67
+ end
68
+ when :elasticache
69
+ lambda do |descriptor|
70
+ require_relative "providers/elasticache"
71
+ Providers::ElastiCache.build(descriptor)
72
+ end
73
+ when :gcp
74
+ lambda do |descriptor|
75
+ require_relative "providers/gcp"
76
+ Providers::GCP.build(descriptor)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedisCloudAuth
4
+ # A marshallable callable used as the redis-client `password`. redis-client
5
+ # invokes `#call(username)` on every (re)connect, so returning a fresh access
6
+ # token here authenticates new connections with a current token.
7
+ #
8
+ # It holds only the descriptor (simple values) and resolves the shared
9
+ # provider from {Registry} at call time, so it can be Marshal.dump'd by
10
+ # Sidekiq (unlike a Proc or a live provider object).
11
+ class TokenCredential
12
+ def initialize(descriptor)
13
+ @descriptor = descriptor
14
+ end
15
+
16
+ def call(_username = nil)
17
+ Registry.fetch(@descriptor).call.last
18
+ rescue StandardError => e
19
+ # redis-client only retries its own connection errors, and failsafes
20
+ # (for example RedisCacheStore) only rescue RedisClient::Error. Map a
21
+ # token-fetch failure to a connection error so a transient token-endpoint
22
+ # outage is retried/handled as a reconnect rather than surfacing as a 500.
23
+ # The original error is kept as the cause.
24
+ require "redis-client"
25
+ raise RedisClient::CannotConnectError, "failed to acquire Redis token: #{e.message}"
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RedisCloudAuth
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ require_relative "redis_cloud_auth/version"
6
+ require_relative "redis_cloud_auth/registry"
7
+ require_relative "redis_cloud_auth/token_credential"
8
+ require_relative "redis_cloud_auth/reauth_middleware"
9
+
10
+ # Rotating-token ("credentials provider") authentication for Redis, usable from
11
+ # any redis-client / redis-rb / Sidekiq consumer.
12
+ #
13
+ # A *provider* turns a cloud identity (Microsoft Entra, Amazon ElastiCache IAM,
14
+ # ...) into short-lived Redis credentials. It is a simple duck type:
15
+ #
16
+ # provider.call # => [username, password] (fresh each call)
17
+ # provider.expires_at # => Time, or nil when no token has been fetched yet
18
+ #
19
+ # Two pieces wire a provider into a connection:
20
+ #
21
+ # * {TokenCredential} is a marshallable callable used as the redis-client
22
+ # `password`. It authenticates every (re)connect with a current token and
23
+ # survives Marshal round-trips (Sidekiq), because it holds only a descriptor
24
+ # and resolves the live provider from {Registry} at call time.
25
+ # * {ReauthMiddleware} is a redis-client middleware that re-issues AUTH on a
26
+ # long-lived connection shortly before its token expires.
27
+ #
28
+ # Usage:
29
+ #
30
+ # RedisCloudAuth.register! # once, at boot
31
+ #
32
+ # descriptor = { provider: :azure, resource: "https://redis.azure.com" }
33
+ # RedisClient.config(
34
+ # host: "cache.example.com", port: 10000, ssl: true,
35
+ # username: "<object-id>",
36
+ # password: RedisCloudAuth.credential(descriptor),
37
+ # custom: { credentials_provider: descriptor }
38
+ # )
39
+ module RedisCloudAuth
40
+ class Error < StandardError; end
41
+
42
+ class << self
43
+ # Logger for re-auth failures (throttled). Defaults to a no-op; consumers
44
+ # (GitLab, mail_room, gitlab-exporter) can inject their own.
45
+ attr_writer :logger
46
+
47
+ def logger
48
+ @logger ||= Logger.new(File::NULL)
49
+ end
50
+
51
+ # The process-global provider registry.
52
+ def registry
53
+ Registry
54
+ end
55
+
56
+ # Convenience: a marshallable callable password for +descriptor+.
57
+ def credential(descriptor)
58
+ TokenCredential.new(descriptor)
59
+ end
60
+
61
+ # Register the re-auth middleware with redis-client. Call once at boot.
62
+ # It is a no-op on connections whose config has no credentials_provider
63
+ # descriptor in `custom`.
64
+ def register!
65
+ require "redis-client"
66
+ RedisClient.register(ReauthMiddleware)
67
+ end
68
+ end
69
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: redis-cloud-auth
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - GitLab
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: redis-client
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0.14'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0.14'
26
+ - !ruby/object:Gem::Dependency
27
+ name: logger
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: Pluggable credentials providers (Microsoft Entra, Amazon ElastiCache
41
+ IAM, ...) that authenticate Redis connections with short-lived tokens, plus a redis-client
42
+ middleware that re-authenticates long-lived connections before the token expires.
43
+ executables: []
44
+ extensions: []
45
+ extra_rdoc_files: []
46
+ files:
47
+ - LICENSE
48
+ - README.md
49
+ - lib/redis_cloud_auth.rb
50
+ - lib/redis_cloud_auth/providers/azure.rb
51
+ - lib/redis_cloud_auth/providers/elasticache.rb
52
+ - lib/redis_cloud_auth/providers/gcp.rb
53
+ - lib/redis_cloud_auth/reauth_middleware.rb
54
+ - lib/redis_cloud_auth/registry.rb
55
+ - lib/redis_cloud_auth/token_credential.rb
56
+ - lib/redis_cloud_auth/version.rb
57
+ homepage: https://gitlab.com/gitlab-org/ruby/gems/redis-cloud-auth
58
+ licenses:
59
+ - MIT
60
+ metadata:
61
+ rubygems_mfa_required: 'true'
62
+ rdoc_options: []
63
+ require_paths:
64
+ - lib
65
+ required_ruby_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '3.1'
70
+ required_rubygems_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ requirements: []
76
+ rubygems_version: 4.0.23
77
+ specification_version: 4
78
+ summary: Rotating-token (cloud identity) authentication for Redis via redis-client
79
+ middleware
80
+ test_files: []