az-identity 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 +7 -0
- data/CHANGELOG.md +4 -0
- data/LICENSE +20 -0
- data/README.md +93 -0
- data/lib/az_identity/azure_environment.rb +32 -0
- data/lib/az_identity/constants.rb +9 -0
- data/lib/az_identity/identity/base_client.rb +89 -0
- data/lib/az_identity/identity/credentials.rb +22 -0
- data/lib/az_identity/identity/default_credentials.rb +57 -0
- data/lib/az_identity/identity/managed_identity_client.rb +46 -0
- data/lib/az_identity/identity/workload_identity_client.rb +72 -0
- data/lib/az_identity/redis_credentials_provider.rb +76 -0
- data/lib/az_identity/version.rb +5 -0
- data/lib/az_identity.rb +23 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8febab2955adfb7bdc0a0df7d91d24bfadb5ec05e395bf9996be4cee357380f5
|
|
4
|
+
data.tar.gz: 752d7f9395c662b2f32cad314f09136378d2df7f1110c9505058d717f767b389
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7bb039d9cf3ee98d194356fbf61cd7330fa573295f8133629a270e70f857125e3763ec66eaa3f9ef931af76701042e9c2b57e8939ab122bc09b57a74480af590
|
|
7
|
+
data.tar.gz: d66d03e045ce23f111d43767aa6e24ced3ee5899fee41ff56e9cdd439319eda34dd370b7efd749668de33baa066030551ab26f211b30cd4d9b73baa592230eed
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 GitLab B.V.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
|
7
|
+
the Software without restriction, including without limitation the rights to
|
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
|
10
|
+
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, FITNESS
|
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# AzIdentity
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/az-identity)
|
|
4
|
+
[](https://gitlab.com/gitlab-org/ruby/gems/az-identity/-/pipelines)
|
|
5
|
+
|
|
6
|
+
Azure identity and authentication library for Ruby.
|
|
7
|
+
|
|
8
|
+
Provides credential acquisition for Azure services including Workload Identity and Managed Identity.
|
|
9
|
+
|
|
10
|
+
## :warning: Not Supported by Microsoft
|
|
11
|
+
|
|
12
|
+
This library is **not supported by Microsoft**. It is developed and maintained by GitLab.
|
|
13
|
+
|
|
14
|
+
Microsoft provides official Azure Identity libraries for Ruby:
|
|
15
|
+
- [azure-identity (Microsoft)](https://github.com/Azure/azure-sdk-for-ruby/tree/master/sdk/identity/azure-identity)
|
|
16
|
+
|
|
17
|
+
If you encounter issues with this library, please file issues on the [GitLab issue tracker](https://gitlab.com/gitlab-org/ruby/gems/az-identity/-/issues), not Microsoft support channels.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
Add this line to your application's Gemfile:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
gem 'az-identity'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
And then execute:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
$ bundle install
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Or install it yourself:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
$ gem install az-identity
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
require 'az_identity'
|
|
43
|
+
|
|
44
|
+
# Get credentials using default chain (Workload Identity -> Managed Identity)
|
|
45
|
+
credentials = AzIdentity.credentials(environment: 'AzureCloud')
|
|
46
|
+
|
|
47
|
+
# Or use specific client directly
|
|
48
|
+
client = AzIdentity::Identity::WorkloadIdentityClient.new(environment: 'AzureCloud')
|
|
49
|
+
credentials = client.fetch_credentials_if_needed
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Environment Variables
|
|
53
|
+
|
|
54
|
+
#### Workload Identity
|
|
55
|
+
- `AZURE_TENANT_ID` - Your Azure tenant ID
|
|
56
|
+
- `AZURE_CLIENT_ID` - Your client/application ID
|
|
57
|
+
- `AZURE_FEDERATED_TOKEN_FILE` - Path to the OIDC token file
|
|
58
|
+
- `AZURE_AUTHORITY_HOST` - (Optional) Override authority URL
|
|
59
|
+
|
|
60
|
+
#### Managed Identity
|
|
61
|
+
- `AZURE_CLIENT_ID` - (Optional) Specific managed identity client ID
|
|
62
|
+
- `IDENTITY_ENDPOINT` - (Optional) Custom IMDS endpoint
|
|
63
|
+
- `IDENTITY_HEADER` - (Optional) Custom header for IMDS
|
|
64
|
+
|
|
65
|
+
#### Azure Environment
|
|
66
|
+
- `AZURE_ENVIRONMENT` - (Optional) Azure cloud: `AzureCloud`, `AzureChinaCloud`, `AzureUSGovernment`
|
|
67
|
+
|
|
68
|
+
## Supported Azure Environments
|
|
69
|
+
|
|
70
|
+
- AzureCloud (default)
|
|
71
|
+
- AzureChinaCloud
|
|
72
|
+
- AzureUSGovernment
|
|
73
|
+
|
|
74
|
+
## Sovereign clouds
|
|
75
|
+
|
|
76
|
+
The OAuth resource/audience (`https://storage.azure.com`, `https://redis.azure.com`) is the same in every Azure cloud; only the Microsoft Entra authority (login host) differs. Cloud selection therefore only affects the authority:
|
|
77
|
+
|
|
78
|
+
- **Workload identity**: the authority comes from `AZURE_AUTHORITY_HOST`, which AKS injects with the correct per-cloud value, so it works in sovereign clouds without further configuration. The `AZURE_ENVIRONMENT` fallback is used only when that variable is unset.
|
|
79
|
+
- **Managed identity**: uses the IMDS endpoint, which is the same in every cloud, so it is cloud-agnostic.
|
|
80
|
+
|
|
81
|
+
Only Azure Public and Azure US Government have been exercised; other clouds rely on the mechanisms above rather than dedicated testing.
|
|
82
|
+
|
|
83
|
+
## Development
|
|
84
|
+
|
|
85
|
+
After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rake test` to run the tests.
|
|
86
|
+
|
|
87
|
+
## Contributing
|
|
88
|
+
|
|
89
|
+
Bug reports and pull requests are welcome on GitLab at https://gitlab.com/gitlab-org/ruby/gems/az-identity.
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'constants'
|
|
4
|
+
|
|
5
|
+
module AzIdentity
|
|
6
|
+
module AzureEnvironment
|
|
7
|
+
# The Microsoft Entra authority (login host) differs per cloud. On AKS with
|
|
8
|
+
# workload identity, AZURE_AUTHORITY_HOST is injected with the correct value
|
|
9
|
+
# and takes precedence over this fallback (see WorkloadIdentityClient).
|
|
10
|
+
def authority_url(environment = AzIdentity::Constants::AZURE_CLOUD)
|
|
11
|
+
case environment
|
|
12
|
+
when AzIdentity::Constants::AZURE_CHINA_CLOUD
|
|
13
|
+
'https://login.partner.microsoftonline.cn'
|
|
14
|
+
when AzIdentity::Constants::AZURE_US_GOVERNMENT
|
|
15
|
+
'https://login.microsoftonline.us'
|
|
16
|
+
else
|
|
17
|
+
'https://login.microsoftonline.com'
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The OAuth resource/audience is the same in every cloud (only the authority
|
|
22
|
+
# differs), so these intentionally ignore the environment.
|
|
23
|
+
# https://learn.microsoft.com/en-us/rest/api/storageservices/authorize-with-azure-active-directory
|
|
24
|
+
def storage_resource(_environment = AzIdentity::Constants::AZURE_CLOUD)
|
|
25
|
+
'https://storage.azure.com'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def redis_resource(_environment = AzIdentity::Constants::AZURE_CLOUD)
|
|
29
|
+
'https://redis.azure.com'
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'faraday'
|
|
5
|
+
require_relative '../azure_environment'
|
|
6
|
+
|
|
7
|
+
module AzIdentity
|
|
8
|
+
module Identity
|
|
9
|
+
class BaseClient
|
|
10
|
+
include AzIdentity::AzureEnvironment
|
|
11
|
+
|
|
12
|
+
class FetchCredentialsError < RuntimeError
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
DEFAULT_TIMEOUT_S = 30
|
|
16
|
+
|
|
17
|
+
attr_accessor :credentials
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def fetch_credentials
|
|
24
|
+
raise NotImplementedError
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def fetch_credentials_if_needed
|
|
28
|
+
# Serialize refreshes so concurrent callers (for example pooled Redis
|
|
29
|
+
# connections across threads) don't each hit the token endpoint.
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
@credentials = fetch_credentials if @credentials.nil? || refresh_needed?
|
|
32
|
+
|
|
33
|
+
credentials
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def refresh_needed?
|
|
38
|
+
return true unless @credentials
|
|
39
|
+
|
|
40
|
+
@credentials.refresh_needed?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
protected
|
|
44
|
+
|
|
45
|
+
def process_token_response(response)
|
|
46
|
+
raise FetchCredentialsError, response.to_s unless response.success?
|
|
47
|
+
|
|
48
|
+
body = ::JSON.parse(response.body)
|
|
49
|
+
access_token = body['access_token']
|
|
50
|
+
|
|
51
|
+
return unless access_token
|
|
52
|
+
|
|
53
|
+
Credentials.new(access_token, expiration_time(body))
|
|
54
|
+
rescue ::JSON::ParserError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# IMDS (managed identity) returns an absolute epoch in +expires_on+, while
|
|
59
|
+
# the Microsoft Entra v2.0 token endpoint (workload identity) returns a
|
|
60
|
+
# relative +expires_in+ in seconds. Returns nil when neither is present,
|
|
61
|
+
# which Credentials treats as "expiry unknown, always refresh".
|
|
62
|
+
def expiration_time(body)
|
|
63
|
+
if (expires_on = body['expires_on'])
|
|
64
|
+
::Time.at(expires_on.to_i)
|
|
65
|
+
elsif (expires_in = body['expires_in'])
|
|
66
|
+
::Time.now + expires_in.to_i
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def get(url, params: nil, headers: nil)
|
|
73
|
+
Faraday.get(url, params, headers) do |req|
|
|
74
|
+
req.options.timeout = DEFAULT_TIMEOUT_S
|
|
75
|
+
end
|
|
76
|
+
rescue ::Faraday::Error => e
|
|
77
|
+
raise FetchCredentialsError, e.to_s
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def post(url, body: nil, headers: nil)
|
|
81
|
+
Faraday.post(url, body, headers) do |req|
|
|
82
|
+
req.options.timeout = DEFAULT_TIMEOUT_S
|
|
83
|
+
end
|
|
84
|
+
rescue ::Faraday::Error => e
|
|
85
|
+
raise FetchCredentialsError, e.to_s
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AzIdentity
|
|
4
|
+
module Identity
|
|
5
|
+
class Credentials
|
|
6
|
+
attr_accessor :token, :expires_at
|
|
7
|
+
|
|
8
|
+
EXPIRATION_BUFFER = 600
|
|
9
|
+
|
|
10
|
+
def initialize(token, expires_at)
|
|
11
|
+
@token = token
|
|
12
|
+
@expires_at = expires_at
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def refresh_needed?
|
|
16
|
+
return true unless expires_at
|
|
17
|
+
|
|
18
|
+
Time.now >= expires_at - EXPIRATION_BUFFER
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'base_client'
|
|
5
|
+
require_relative 'workload_identity_client'
|
|
6
|
+
require_relative 'managed_identity_client'
|
|
7
|
+
|
|
8
|
+
module AzIdentity
|
|
9
|
+
class DefaultCredentials
|
|
10
|
+
CLIENTS = [
|
|
11
|
+
AzIdentity::Identity::WorkloadIdentityClient,
|
|
12
|
+
AzIdentity::Identity::ManagedIdentityClient
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
def initialize(options = {})
|
|
16
|
+
@options = options
|
|
17
|
+
@credential_client = nil
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def fetch_credentials_if_needed
|
|
22
|
+
return unless credential_client
|
|
23
|
+
|
|
24
|
+
credential_client.fetch_credentials_if_needed
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
attr_reader :options
|
|
30
|
+
|
|
31
|
+
def credential_client
|
|
32
|
+
return @credential_client if @credential_client
|
|
33
|
+
|
|
34
|
+
@mutex.synchronize do
|
|
35
|
+
@credential_client ||= discover_client
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def discover_client
|
|
40
|
+
CLIENTS.each do |klass|
|
|
41
|
+
client = klass.new(options)
|
|
42
|
+
return client if try_fetch_credentials(client)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A credential source that is not configured in this environment should be
|
|
49
|
+
# skipped so the next one is tried, rather than aborting discovery of the
|
|
50
|
+
# remaining sources.
|
|
51
|
+
def try_fetch_credentials(client)
|
|
52
|
+
client.fetch_credentials_if_needed
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'faraday'
|
|
5
|
+
require 'cgi'
|
|
6
|
+
require_relative 'base_client'
|
|
7
|
+
|
|
8
|
+
module AzIdentity
|
|
9
|
+
module Identity
|
|
10
|
+
IDENTITY_ENDPOINT = 'http://169.254.169.254/metadata/identity/oauth2/token'
|
|
11
|
+
API_VERSION = '2018-02-01'
|
|
12
|
+
|
|
13
|
+
class ManagedIdentityClient < BaseClient
|
|
14
|
+
attr_reader :resource
|
|
15
|
+
|
|
16
|
+
def initialize(options = {})
|
|
17
|
+
super()
|
|
18
|
+
@environment = options[:environment]
|
|
19
|
+
@resource = options[:resource] || storage_resource(@environment)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def fetch_credentials
|
|
23
|
+
url = "#{identity_endpoint}?api-version=#{api_version}&resource=#{CGI.escape(resource)}"
|
|
24
|
+
|
|
25
|
+
client_id = ENV.fetch('AZURE_CLIENT_ID', nil)
|
|
26
|
+
url += "&client_id=#{client_id}" if client_id
|
|
27
|
+
|
|
28
|
+
headers = { 'Metadata' => 'true' }
|
|
29
|
+
headers['X-IDENTITY-HEADER'] = ENV['IDENTITY_HEADER'] if ENV['IDENTITY_HEADER']
|
|
30
|
+
|
|
31
|
+
response = get(url, headers: headers)
|
|
32
|
+
process_token_response(response)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def identity_endpoint
|
|
38
|
+
ENV['IDENTITY_ENDPOINT'] || IDENTITY_ENDPOINT
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def api_version
|
|
42
|
+
ENV['IDENTITY_ENDPOINT'] ? '2019-08-01' : API_VERSION
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'faraday'
|
|
5
|
+
require_relative 'base_client'
|
|
6
|
+
|
|
7
|
+
module AzIdentity
|
|
8
|
+
module Identity
|
|
9
|
+
class WorkloadIdentityClient < BaseClient
|
|
10
|
+
attr_accessor :environment, :resource, :authority, :tenant_id, :client_id, :token_file
|
|
11
|
+
|
|
12
|
+
def initialize(options = {})
|
|
13
|
+
super()
|
|
14
|
+
@environment = options[:environment]
|
|
15
|
+
@resource = options[:resource] || storage_resource(@environment)
|
|
16
|
+
@authority = ENV['AZURE_AUTHORITY_HOST'] || authority_url(@environment)
|
|
17
|
+
@tenant_id = ENV.fetch('AZURE_TENANT_ID', nil)
|
|
18
|
+
@client_id = ENV.fetch('AZURE_CLIENT_ID', nil)
|
|
19
|
+
@token_file = ENV.fetch('AZURE_FEDERATED_TOKEN_FILE', nil)
|
|
20
|
+
|
|
21
|
+
normalize_authority!
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def fetch_credentials
|
|
25
|
+
return unless federated_credentials_available?
|
|
26
|
+
|
|
27
|
+
response = post(token_endpoint, body: token_request_body)
|
|
28
|
+
process_token_response(response)
|
|
29
|
+
rescue ::Faraday::Error => e
|
|
30
|
+
raise FetchCredentialsError, e.to_s
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def federated_credentials_available?
|
|
36
|
+
return false unless authority && tenant_id && client_id && token_file
|
|
37
|
+
|
|
38
|
+
::File.exist?(token_file) && ::File.readable?(token_file)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def token_endpoint
|
|
42
|
+
"#{authority}/#{tenant_id}/oauth2/v2.0/token"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def token_request_body
|
|
46
|
+
{
|
|
47
|
+
client_id: client_id,
|
|
48
|
+
grant_type: 'client_credentials',
|
|
49
|
+
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
|
50
|
+
client_assertion: ::File.read(token_file),
|
|
51
|
+
scope: "#{resource}/.default"
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def normalize_authority!
|
|
56
|
+
parsed = URI.parse(authority)
|
|
57
|
+
|
|
58
|
+
unless parsed.scheme
|
|
59
|
+
@authority = "https://#{authority.strip.chomp('/')}"
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
unless parsed.scheme == 'https'
|
|
64
|
+
raise ArgumentError,
|
|
65
|
+
"'#{authority}' is an invalid authority. The value must be a TLS protected (https) URL."
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
@authority = authority.strip.chomp('/')
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'json'
|
|
5
|
+
require_relative 'azure_environment'
|
|
6
|
+
require_relative 'identity/default_credentials'
|
|
7
|
+
|
|
8
|
+
module AzIdentity
|
|
9
|
+
# Adapts Azure identity credential acquisition to the credentials-provider
|
|
10
|
+
# interface expected by redis-client / redis-rb:
|
|
11
|
+
#
|
|
12
|
+
# provider.call # => [username, password]
|
|
13
|
+
# provider.expires_at # => Time or nil
|
|
14
|
+
#
|
|
15
|
+
# The password is a Microsoft Entra access token for the Azure Cache for
|
|
16
|
+
# Redis scope. The username is the object (principal) ID of the managed
|
|
17
|
+
# identity or service principal, taken from the token's +oid+ claim unless
|
|
18
|
+
# an explicit +:username+ is supplied.
|
|
19
|
+
class RedisCredentialsProvider
|
|
20
|
+
include AzureEnvironment
|
|
21
|
+
|
|
22
|
+
def initialize(options = {})
|
|
23
|
+
@username = options[:username]
|
|
24
|
+
@credential_client = options[:credential_client] || build_default_client(options)
|
|
25
|
+
@mutex = Mutex.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Returns fresh [username, access_token]. Acquires or refreshes the token
|
|
29
|
+
# as needed. Called by the Redis client on every (re)connect and before a
|
|
30
|
+
# command whenever the token is close to expiring.
|
|
31
|
+
def call
|
|
32
|
+
credentials = @mutex.synchronize do
|
|
33
|
+
@credentials = @credential_client.fetch_credentials_if_needed
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
raise Identity::BaseClient::FetchCredentialsError, 'no Azure credentials available for Redis' unless credentials
|
|
37
|
+
|
|
38
|
+
[username(credentials.token), credentials.token]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The Time at which the current token expires, so the Redis client can
|
|
42
|
+
# re-authenticate long-lived connections before the token is rejected.
|
|
43
|
+
def expires_at
|
|
44
|
+
@mutex.synchronize { @credentials&.expires_at }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def build_default_client(options)
|
|
50
|
+
resource = options[:resource] || redis_resource(options[:environment])
|
|
51
|
+
DefaultCredentials.new(options.merge(resource: resource))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def username(token)
|
|
55
|
+
@username || object_id_from_token(token)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Extracts the +oid+ (object ID) claim from the JWT access token without
|
|
59
|
+
# verifying its signature. Azure Cache for Redis expects the AUTH username
|
|
60
|
+
# to be the managed identity / service principal object ID.
|
|
61
|
+
def object_id_from_token(token)
|
|
62
|
+
payload = token.to_s.split('.')[1]
|
|
63
|
+
return unless payload
|
|
64
|
+
|
|
65
|
+
json = Base64.urlsafe_decode64(pad_base64(payload))
|
|
66
|
+
JSON.parse(json)['oid']
|
|
67
|
+
rescue ArgumentError, JSON::ParserError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def pad_base64(str)
|
|
72
|
+
remainder = str.length % 4
|
|
73
|
+
remainder.zero? ? str : str + ('=' * (4 - remainder))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/az_identity.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'az_identity/identity/base_client'
|
|
4
|
+
require_relative 'az_identity/identity/credentials'
|
|
5
|
+
require_relative 'az_identity/identity/default_credentials'
|
|
6
|
+
require_relative 'az_identity/identity/managed_identity_client'
|
|
7
|
+
require_relative 'az_identity/identity/workload_identity_client'
|
|
8
|
+
require_relative 'az_identity/redis_credentials_provider'
|
|
9
|
+
|
|
10
|
+
module AzIdentity
|
|
11
|
+
class << self
|
|
12
|
+
def credentials(options = {})
|
|
13
|
+
AzIdentity::DefaultCredentials.new(options).fetch_credentials_if_needed
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Builds a credentials provider suitable for authenticating to Azure Cache
|
|
17
|
+
# for Redis with a Microsoft Entra token. Pass the result as the
|
|
18
|
+
# +credentials_provider+ option to redis-client / redis-rb.
|
|
19
|
+
def redis_credentials_provider(options = {})
|
|
20
|
+
AzIdentity::RedisCredentialsProvider.new(options)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: az-identity
|
|
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: base64
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: faraday
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
40
|
+
description: Credential acquisition for Azure services using workload identity or
|
|
41
|
+
managed identity
|
|
42
|
+
executables: []
|
|
43
|
+
extensions: []
|
|
44
|
+
extra_rdoc_files: []
|
|
45
|
+
files:
|
|
46
|
+
- CHANGELOG.md
|
|
47
|
+
- LICENSE
|
|
48
|
+
- README.md
|
|
49
|
+
- lib/az_identity.rb
|
|
50
|
+
- lib/az_identity/azure_environment.rb
|
|
51
|
+
- lib/az_identity/constants.rb
|
|
52
|
+
- lib/az_identity/identity/base_client.rb
|
|
53
|
+
- lib/az_identity/identity/credentials.rb
|
|
54
|
+
- lib/az_identity/identity/default_credentials.rb
|
|
55
|
+
- lib/az_identity/identity/managed_identity_client.rb
|
|
56
|
+
- lib/az_identity/identity/workload_identity_client.rb
|
|
57
|
+
- lib/az_identity/redis_credentials_provider.rb
|
|
58
|
+
- lib/az_identity/version.rb
|
|
59
|
+
homepage: https://gitlab.com/gitlab-org/ruby/gems/az-identity
|
|
60
|
+
licenses:
|
|
61
|
+
- MIT
|
|
62
|
+
metadata:
|
|
63
|
+
rubygems_mfa_required: 'true'
|
|
64
|
+
rdoc_options: []
|
|
65
|
+
require_paths:
|
|
66
|
+
- lib
|
|
67
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
68
|
+
requirements:
|
|
69
|
+
- - ">="
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: 3.0.0
|
|
72
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
73
|
+
requirements:
|
|
74
|
+
- - ">="
|
|
75
|
+
- !ruby/object:Gem::Version
|
|
76
|
+
version: '0'
|
|
77
|
+
requirements: []
|
|
78
|
+
rubygems_version: 3.7.2
|
|
79
|
+
specification_version: 4
|
|
80
|
+
summary: Azure identity and authentication library for Ruby
|
|
81
|
+
test_files: []
|