File: login_credentials.rb

package info (click to toggle)
ruby-aws-sdk-core 3.242.0-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 2,420 kB
  • sloc: ruby: 18,795; makefile: 4
file content (229 lines) | stat: -rw-r--r-- 9,378 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# frozen_string_literal: true

module Aws
  # An auto-refreshing credential provider that retrieves credentials from
  # a cached login token. This class does NOT implement the AWS Sign-In
  # login flow - tokens must be generated separately by running `aws login`
  # from the AWS CLI/AWS Tools for PowerShell with the correct profile.
  # The {LoginCredentials} will auto-refresh the AWS credentials from AWS Sign-In.
  #
  #     # You must first run aws login --profile your-login-profile
  #     login_credentials = Aws::LoginCredentials.new(login_session: 'my_login_session')
  #     ec2 = Aws::EC2::Client.new(credentials: login_credentials)
  #
  # If you omit the `:client` option, a new {Aws::Signin::Client} object will
  # be constructed with additional options that were provided.
  class LoginCredentials
    include CredentialProvider
    include RefreshingCredentials

    # @option options [required, String] :login_session An opaque string
    #   used to determine the cache file location. This value can be found
    #   in the AWS config file which is set by the AWS CLI/AWS Tools for
    #   PowerShell automatically.
    #
    # @option options [Signin::Client] :client Optional `Signin::Client`.
    #   If not provided, a client will be constructed.
    def initialize(options = {})
      raise ArgumentError, 'Missing login_session' unless options[:login_session]

      @login_session = options.delete(:login_session)
      @client = options[:client]
      unless @client
        client_opts = options.reject { |key, _| CLIENT_EXCLUDE_OPTIONS.include?(key) }
        @client = Signin::Client.new(client_opts.merge(credentials: nil))
      end
      @metrics = ['CREDENTIALS_LOGIN']
      @async_refresh = true
      super
    end

    # @return [Signin::Client]
    attr_reader :client

    private

    def refresh
      # First reload the token from disk to ensure it hasn't been refreshed externally
      token_json = read_cached_token
      update_creds(token_json['accessToken'])
      return if @credentials && @expiration && !near_expiration?(sync_expiration_length)

      # Using OpenSSL 3.6.0 may result in errors like "certificate verify failed (unable to get certificate CRL)."
      # A recommended workaround is to use OpenSSL version < 3.6.0 or requiring the openssl gem with a version of at
      # least 3.2.2. GitHub issue: https://github.com/openssl/openssl/issues/28752.
      if OpenSSL::OPENSSL_LIBRARY_VERSION.include?('3.6.') &&
         (!Gem.loaded_specs['openssl'] || Gem.loaded_specs['openssl'].version < Gem::Version.new('3.2.2'))
        warn 'WARNING: OpenSSL 3.6.x may cause certificate verify errors - use OpenSSL < 3.6.0 or openssl gem >= 3.2.2'
      end

      # Attempt to refresh the token
      attempt_refresh(token_json)

      # Raise if token is hard expired
      return unless !@expiration || @expiration < Time.now

      raise Errors::InvalidLoginToken,
            'Login token is invalid and failed to refresh. Please reauthenticate.'
    end

    def read_cached_token
      cached_token = JSON.load_file(login_cache_file)
      validate_cached_token(cached_token)
      cached_token
    rescue Errno::ENOENT, Aws::Json::ParseError
      raise Errors::InvalidLoginToken,
            "Failed to load a Login token for login session #{@login_session}. Please reauthenticate."
    end

    def login_cache_file
      directory = ENV['AWS_LOGIN_CACHE_DIRECTORY'] || File.join(Dir.home, '.aws', 'login', 'cache')
      login_session_sha = OpenSSL::Digest::SHA256.hexdigest(@login_session.strip.encode('utf-8'))
      File.join(directory, "#{login_session_sha}.json")
    end

    def validate_cached_token(cached_token)
      required_cached_token_fields = %w[accessToken clientId refreshToken dpopKey]
      missing_fields = required_cached_token_fields.reject { |field| cached_token[field] }
      unless missing_fields.empty?
        raise ArgumentError, "Cached login token is missing required field(s): #{missing_fields}. " \
          'Please reauthenticate.'
      end

      access_token = cached_token['accessToken']
      required_access_token_fields = %w[accessKeyId secretAccessKey sessionToken accountId expiresAt]
      missing_fields = required_access_token_fields.reject { |field| access_token[field] }

      return if missing_fields.empty?

      raise ArgumentError, "Access token in cached login token is missing required field(s): #{missing_fields}. " \
        'Please reauthenticate.'
    end

    def update_creds(access_token)
      @credentials = Credentials.new(
        access_token['accessKeyId'],
        access_token['secretAccessKey'],
        access_token['sessionToken'],
        account_id: access_token['accountId']
      )
      @expiration = Time.parse(access_token['expiresAt'])
    end

    def attempt_refresh(token_json)
      resp = make_request(token_json)
      parse_resp(resp.token_output, token_json)
      update_creds(token_json['accessToken'])
      update_token_cache(token_json)
    rescue Signin::Errors::AccessDeniedException => e
      case e.error
      when 'TOKEN_EXPIRED'
        warn 'Your session has expired. Please reauthenticate.'
      when 'USER_CREDENTIALS_CHANGED'
        warn 'Unable to refresh credentials because of a change in your password. ' \
          'Please reauthenticate with your new password.'
      when 'INSUFFICIENT_PERMISSIONS'
        warn 'Unable to refresh credentials due to insufficient permissions. ' \
          'You may be missing permission for the `CreateOAuth2Token` action.'
      end
    rescue StandardError => e
      warn("Failed to refresh Login token for LoginCredentials: #{e.message}")
    end

    def make_request(token_json)
      options = {
        token_input: {
          client_id: token_json['clientId'],
          grant_type: 'refresh_token',
          refresh_token: token_json['refreshToken']
        }
      }
      req = @client.build_request(:create_o_auth_2_token, options)
      endpoint_params = Aws::Signin::EndpointParameters.create(req.context.config)
      endpoint = req.context.config.endpoint_provider.resolve_endpoint(endpoint_params)
      endpoint = URI.join(endpoint.url, @client.config.api.operation(:create_o_auth_2_token).http_request_uri).to_s
      req.context.http_request.headers['DPoP'] = dpop_proof(token_json['dpopKey'], endpoint)
      req.send_request
    end

    def dpop_proof(dpop_key, endpoint)
      # Load private key from cached token file
      private_key = OpenSSL::PKey.read(dpop_key)
      public_key = private_key.public_key.to_octet_string(:uncompressed)

      # Construct header and payload
      header = build_header(public_key[1, 32], public_key[33, 32])
      payload = build_payload(endpoint)

      # Base64URL encode header and payload, sign message using private key, and create header
      message = build_message(header, payload)
      signature = private_key.sign(OpenSSL::Digest.new('SHA256'), message)
      jws_signature = der_to_jws(signature)
      "#{message}.#{Base64.urlsafe_encode64(jws_signature, padding: false)}"
    end

    def build_header(x_bytes, y_bytes)
      {
        'alg' => 'ES256', # signing algorithm
        'jwk' => {
          'crv' => 'P-256', # curve name
          'kty' => 'EC', # key type
          'x' => Base64.urlsafe_encode64(x_bytes, padding: false), # public x coordinate
          'y' => Base64.urlsafe_encode64(y_bytes, padding: false) # public y coordinate
        },
        'typ' => 'dpop+jwt' # hardcoded
      }
    end

    def build_payload(htu)
      {
        'jti' => SecureRandom.uuid, # unique identifier (UUID4)
        'htm' => @client.config.api.operation(:create_o_auth_2_token).http_method, # POST
        'htu' => htu, # endpoint of the CreateOAuth2Token operation, with path
        'iat' => Time.now.utc.to_i # UTC timestamp, specified number of seconds from 1970-01-01T00:00:00Z UTC
      }
    end

    def build_message(header, payload)
      encoded_header = Base64.urlsafe_encode64(JSON.dump(header), padding: false)
      encoded_payload = Base64.urlsafe_encode64(JSON.dump(payload), padding: false)
      "#{encoded_header}.#{encoded_payload}"
    end

    # Converts DER-encoded ASN.1 signature to JWS
    def der_to_jws(der_signature)
      asn1 = OpenSSL::ASN1.decode(der_signature)
      r = asn1.value[0].value
      s = asn1.value[1].value

      r_hex = r.to_s(16).rjust(64, '0')
      s_hex = s.to_s(16).rjust(64, '0')

      [r_hex + s_hex].pack('H*')
    end

    def parse_resp(resp, token_json)
      access_token = token_json['accessToken']
      access_token.merge!(
        'accessKeyId' => resp.access_token.access_key_id,
        'secretAccessKey' => resp.access_token.secret_access_key,
        'sessionToken' => resp.access_token.session_token,
        'expiresAt' => (Time.now.utc + resp.expires_in).to_datetime.rfc3339
      )
      token_json['refreshToken'] = resp.refresh_token
    end

    def update_token_cache(token_json)
      cached_token = token_json.dup
      # File.write is not atomic so use temp file and move
      temp_file = Tempfile.new('temp_file')
      begin
        temp_file.write(Json.dump(cached_token))
        temp_file.close
        FileUtils.mv(temp_file.path, login_cache_file)
      ensure
        temp_file.unlink if File.exist?(temp_file.path) # Ensure temp file is cleaned up
      end
    end
  end
end