File: access_token.rb

package info (click to toggle)
ruby-oauth2 2.0.18-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 2,196 kB
  • sloc: ruby: 5,441; javascript: 529; sh: 4; makefile: 4
file content (420 lines) | stat: -rw-r--r-- 17,529 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
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
# frozen_string_literal: true

# :nocov:
begin
  # The first version of hashie that has a version file was 1.1.0
  # The first version of hashie that required the version file at runtime was 3.2.0
  # If it has already been loaded then this is very low cost, as Kernel.require uses maintains a cache
  # If this it hasn't this will work to get it loaded, and then we will be able to use
  #   defined?(Hashie::Version)
  # as a test.
  # TODO: get rid this mess when we drop Hashie < 3.2, as Hashie will self-load its version then
  require "hashie/version"
rescue LoadError
  nil
end
# :nocov:

module OAuth2
  class AccessToken # rubocop:disable Metrics/ClassLength
    TOKEN_KEYS_STR = %w[access_token id_token token accessToken idToken].freeze
    TOKEN_KEYS_SYM = %i[access_token id_token token accessToken idToken].freeze
    TOKEN_KEY_LOOKUP = TOKEN_KEYS_STR + TOKEN_KEYS_SYM

    include FilteredAttributes

    attr_reader :client, :token, :expires_in, :expires_at, :expires_latency, :params
    attr_accessor :options, :refresh_token, :response
    filtered_attributes :token, :refresh_token

    class << self
      # Initializes an AccessToken from a Hash
      #
      # @param [OAuth2::Client] client the OAuth2::Client instance
      # @param [Hash] hash a hash containing the token and other properties
      # @option hash [String] 'access_token' the access token value
      # @option hash [String] 'id_token' alternative key for the access token value
      # @option hash [String] 'token' alternative key for the access token value
      # @option hash [String] 'refresh_token' (optional) the refresh token value
      # @option hash [Integer, String] 'expires_in' (optional) number of seconds until token expires
      # @option hash [Integer, String] 'expires_at' (optional) epoch time in seconds when token expires
      # @option hash [Integer, String] 'expires_latency' (optional) seconds to reduce token validity by
      #
      # @return [OAuth2::AccessToken] the initialized AccessToken
      #
      # @note The method will use the first found token key in the following order:
      #   'access_token', 'id_token', 'token' (or their symbolic versions)
      # @note If multiple token keys are present, a warning will be issued unless
      #   OAuth2.config.silence_extra_tokens_warning is true
      # @note If no token keys are present, a warning will be issued unless
      #   OAuth2.config.silence_no_tokens_warning is true
      # @note For "soon-to-expire"/"clock-skew" functionality see the `:expires_latency` option.
      # @note If snaky key conversion is being used, token_name needs to match the converted key.
      #
      # @example
      #   hash = { 'access_token' => 'token_value', 'refresh_token' => 'refresh_value' }
      #   access_token = OAuth2::AccessToken.from_hash(client, hash)
      def from_hash(client, hash)
        fresh = hash.dup
        # If token_name is present, then use that key name
        key =
          if fresh.key?(:token_name)
            t_key = fresh[:token_name]
            no_tokens_warning(fresh, t_key)
            t_key
          else
            # Otherwise, if one of the supported default keys is present, use whichever has precedence
            supported_keys = TOKEN_KEY_LOOKUP & fresh.keys
            t_key = supported_keys[0]
            extra_tokens_warning(supported_keys, t_key)
            t_key
          end
        # :nocov:
        # TODO: Get rid of this branching logic when dropping Hashie < v3.2
        token = if !defined?(Hashie::VERSION) # i.e. <= "1.1.0"; the first Hashie to ship with a VERSION constant
          warn("snaky_hash and oauth2 will drop support for Hashie v0 in the next major version. Please upgrade to a modern Hashie.")
          # There is a bug in Hashie v0, which is accounts for.
          fresh.delete(key) || fresh[key] || ""
        else
          fresh.delete(key) || ""
        end
        # :nocov:
        new(client, token, fresh)
      end

      # Initializes an AccessToken from a key/value application/x-www-form-urlencoded string
      #
      # @param [Client] client the OAuth2::Client instance
      # @param [String] kvform the application/x-www-form-urlencoded string
      # @return [AccessToken] the initialized AccessToken
      def from_kvform(client, kvform)
        from_hash(client, Rack::Utils.parse_query(kvform))
      end

    private

      # Having too many is sus, and may lead to bugs. Having none is fine (e.g. refresh flow doesn't need a token).
      def extra_tokens_warning(supported_keys, key)
        return if OAuth2.config.silence_extra_tokens_warning
        return if supported_keys.length <= 1

        warn("OAuth2::AccessToken.from_hash: `hash` contained more than one 'token' key (#{supported_keys}); using #{key.inspect}.")
      end

      def no_tokens_warning(hash, key)
        return if OAuth2.config.silence_no_tokens_warning
        return if key && hash.key?(key)

        warn(%[
OAuth2::AccessToken#from_hash key mismatch.
Custom token_name (#{key}) is not found in (#{hash.keys})
You may need to set `snaky: false`. See inline documentation for more info.
        ])
      end
    end

    # Initialize an AccessToken
    #
    # @note For "soon-to-expire"/"clock-skew" functionality see the `:expires_latency` option.
    # @note If no token is provided, the AccessToken will be considered invalid.
    #   This is to prevent the possibility of a token being accidentally
    #   created with no token value.
    #   If you want to create an AccessToken with no token value,
    #   you can pass in an empty string or nil for the token value.
    #   If you want to create an AccessToken with no token value and
    #   no refresh token, you can pass in an empty string or nil for the
    #   token value and nil for the refresh token, and `raise_errors: false`.
    #
    # @param [Client] client the OAuth2::Client instance
    # @param [String] token the Access Token value (optional, may not be used in refresh flows)
    # @param [Hash] opts the options to create the Access Token with
    # @option opts [String] :refresh_token (nil) the refresh_token value
    # @option opts [FixNum, String] :expires_in (nil) the number of seconds in which the AccessToken will expire
    # @option opts [FixNum, String] :expires_at (nil) the epoch time in seconds in which AccessToken will expire
    # @option opts [FixNum, String] :expires_latency (nil) the number of seconds by which AccessToken validity will be reduced to offset latency, @version 2.0+
    # @option opts [Symbol, Hash, or callable] :mode (:header) the transmission mode of the Access Token parameter value:
    #    either one of :header, :body or :query; or a Hash with verb symbols as keys mapping to one of these symbols
    #    (e.g., `{get: :query, post: :header, delete: :header}`); or a callable that accepts a request-verb parameter
    #    and returns one of these three symbols.
    # @option opts [String] :header_format ('Bearer %s') the string format to use for the Authorization header
    #
    # @example Verb-dependent Hash mode
    #   # Send token in query for GET, in header for POST/DELETE, in body for PUT/PATCH
    #   OAuth2::AccessToken.new(client, token, mode: {get: :query, post: :header, delete: :header, put: :body, patch: :body})
    # @option opts [String] :param_name ('access_token') the parameter name to use for transmission of the
    #    Access Token value in :body or :query transmission mode
    # @option opts [String] :token_name (nil) the name of the response parameter that identifies the access token
    #    When nil one of TOKEN_KEY_LOOKUP will be used
    def initialize(client, token, opts = {})
      @client = client
      @token = token.to_s
      opts = opts.dup
      %i[refresh_token expires_in expires_at expires_latency].each do |arg|
        instance_variable_set("@#{arg}", opts.delete(arg) || opts.delete(arg.to_s))
      end
      no_tokens = (@token.nil? || @token.empty?) && (@refresh_token.nil? || @refresh_token.empty?)
      if no_tokens
        if @client.options[:raise_errors]
          raise Error.new({
            error: "OAuth2::AccessToken has no token",
            error_description: "Options are: #{opts.inspect}",
          })
        elsif !OAuth2.config.silence_no_tokens_warning
          warn("OAuth2::AccessToken has no token")
        end
      end
      # @option opts [Fixnum, String] :expires is deprecated
      @expires_in ||= opts.delete("expires")
      @expires_in &&= @expires_in.to_i
      @expires_at &&= convert_expires_at(@expires_at)
      @expires_latency &&= @expires_latency.to_i
      @expires_at ||= Time.now.to_i + @expires_in if @expires_in && !@expires_in.zero?
      @expires_at -= @expires_latency if @expires_latency
      @options = {
        mode: opts.delete(:mode) || :header,
        header_format: opts.delete(:header_format) || "Bearer %s",
        param_name: opts.delete(:param_name) || "access_token",
      }
      @options[:token_name] = opts.delete(:token_name) if opts.key?(:token_name)

      @params = opts
    end

    # Indexer to additional params present in token response
    #
    # @param [String] key entry key to Hash
    def [](key)
      @params[key]
    end

    # Whether the token expires
    #
    # @return [Boolean]
    def expires?
      !!@expires_at
    end

    # Check if token is expired
    #
    # @return [Boolean] true if the token is expired, false otherwise
    def expired?
      expires? && (expires_at <= Time.now.to_i)
    end

    # Refreshes the current Access Token
    #
    # @param [Hash] params additional params to pass to the refresh token request
    # @param [Hash] access_token_opts options that will be passed to the AccessToken initialization
    #
    # @yield [opts] The block to modify the refresh token request options
    # @yieldparam [Hash] opts The options hash that can be modified
    #
    # @return [OAuth2::AccessToken] a new AccessToken instance
    #
    # @note current token's options are carried over to the new AccessToken
    def refresh(params = {}, access_token_opts = {}, &block)
      raise OAuth2::Error.new({error: "A refresh_token is not available"}) unless refresh_token

      params[:grant_type] = "refresh_token"
      params[:refresh_token] = refresh_token
      new_token = @client.get_token(params, access_token_opts, &block)
      new_token.options = options
      if new_token.refresh_token
        # Keep it if there is one
      else
        new_token.refresh_token = refresh_token
      end
      new_token
    end
    # A compatibility alias
    # @note does not modify the receiver, so bang is not the default method
    alias_method :refresh!, :refresh

    # Revokes the token at the authorization server
    #
    # @param [Hash] params additional parameters to be sent during revocation
    # @option params [String, Symbol, nil] :token_type_hint ('access_token' or 'refresh_token') hint about which token to revoke
    # @option params [Symbol] :token_method (:post_with_query_string) overrides OAuth2::Client#options[:token_method]
    #
    # @yield [req] The block is passed the request being made, allowing customization
    # @yieldparam [Faraday::Request] req The request object that can be modified
    #
    # @return [OAuth2::Response] OAuth2::Response instance
    #
    # @api public
    #
    # @raise [OAuth2::Error] if token_type_hint is invalid or the specified token is not available
    #
    # @note If the token passed to the request
    #    is an access token, the server MAY revoke the respective refresh
    #    token as well.
    # @note If the token passed to the request
    #    is a refresh token and the authorization server supports the
    #    revocation of access tokens, then the authorization server SHOULD
    #    also invalidate all access tokens based on the same authorization
    #    grant
    # @note If the server responds with HTTP status code 503, your code must
    #    assume the token still exists and may retry after a reasonable delay.
    #    The server may include a "Retry-After" header in the response to
    #    indicate how long the service is expected to be unavailable to the
    #    requesting client.
    #
    # @see https://datatracker.ietf.org/doc/html/rfc7009
    # @see https://datatracker.ietf.org/doc/html/rfc7009#section-2.1
    def revoke(params = {}, &block)
      token_type_hint_orig = params.delete(:token_type_hint)
      token_type_hint = nil
      revoke_token = case token_type_hint_orig
      when "access_token", :access_token
        token_type_hint = "access_token"
        token
      when "refresh_token", :refresh_token
        token_type_hint = "refresh_token"
        refresh_token
      when nil
        if token
          token_type_hint = "access_token"
          token
        elsif refresh_token
          token_type_hint = "refresh_token"
          refresh_token
        end
      else
        raise OAuth2::Error.new({error: "token_type_hint must be one of [nil, :refresh_token, :access_token], so if you need something else consider using a subclass or entirely custom AccessToken class."})
      end
      raise OAuth2::Error.new({error: "#{token_type_hint || "unknown token type"} is not available for revoking"}) unless revoke_token && !revoke_token.empty?

      @client.revoke_token(revoke_token, token_type_hint, params, &block)
    end
    # A compatibility alias
    # @note does not modify the receiver, so bang is not the default method
    alias_method :revoke!, :revoke

    # Convert AccessToken to a hash which can be used to rebuild itself with AccessToken.from_hash
    #
    # @note Don't return expires_latency because it has already been deducted from expires_at
    #
    # @return [Hash] a hash of AccessToken property values
    def to_hash
      hsh = {
        access_token: token,
        refresh_token: refresh_token,
        expires_at: expires_at,
        mode: options[:mode],
        header_format: options[:header_format],
        param_name: options[:param_name],
      }
      hsh[:token_name] = options[:token_name] if options.key?(:token_name)
      # TODO: Switch when dropping Ruby < 2.5 support
      # params.transform_keys(&:to_sym) # Ruby 2.5 only
      # Old Ruby transform_keys alternative:
      sheesh = @params.each_with_object({}) { |(k, v), memo|
        memo[k.to_sym] = v
      }
      sheesh.merge(hsh)
    end

    # Make a request with the Access Token
    #
    # @param [Symbol] verb the HTTP request method
    # @param [String] path the HTTP URL path of the request
    # @param [Hash] opts the options to make the request with
    # @option opts [Hash] :params additional URL parameters
    # @option opts [Hash, String] :body the request body
    # @option opts [Hash] :headers request headers
    #
    # @yield [req] The block to modify the request
    # @yieldparam [Faraday::Request] req The request object that can be modified
    #
    # @return [OAuth2::Response] the response from the request
    #
    # @see OAuth2::Client#request
    def request(verb, path, opts = {}, &block)
      configure_authentication!(opts, verb)
      @client.request(verb, path, opts, &block)
    end

    # Make a GET request with the Access Token
    #
    # @see AccessToken#request
    def get(path, opts = {}, &block)
      request(:get, path, opts, &block)
    end

    # Make a POST request with the Access Token
    #
    # @see AccessToken#request
    def post(path, opts = {}, &block)
      request(:post, path, opts, &block)
    end

    # Make a PUT request with the Access Token
    #
    # @see AccessToken#request
    def put(path, opts = {}, &block)
      request(:put, path, opts, &block)
    end

    # Make a PATCH request with the Access Token
    #
    # @see AccessToken#request
    def patch(path, opts = {}, &block)
      request(:patch, path, opts, &block)
    end

    # Make a DELETE request with the Access Token
    #
    # @see AccessToken#request
    def delete(path, opts = {}, &block)
      request(:delete, path, opts, &block)
    end

    # Get the headers hash (includes Authorization token)
    def headers
      {"Authorization" => options[:header_format] % token}
    end

  private

    def configure_authentication!(opts, verb)
      mode_opt = options[:mode]
      mode =
        if mode_opt.respond_to?(:call)
          mode_opt.call(verb)
        elsif mode_opt.is_a?(Hash)
          key = verb.to_sym
          # Try symbol key first, then string key; default to :header when missing
          mode_opt[key] || mode_opt[key.to_s] || :header
        else
          mode_opt
        end

      case mode
      when :header
        opts[:headers] ||= {}
        opts[:headers].merge!(headers)
      when :query
        # OAuth 2.1 note: Bearer tokens in the query string are omitted from the spec due to security risks.
        # Prefer the default :header mode whenever possible.
        opts[:params] ||= {}
        opts[:params][options[:param_name]] = token
      when :body
        opts[:body] ||= {}
        if opts[:body].is_a?(Hash)
          opts[:body][options[:param_name]] = token
        else
          opts[:body] += "&#{options[:param_name]}=#{token}"
        end
        # @todo support for multi-part (file uploads)
      else
        raise("invalid :mode option of #{mode}")
      end
    end

    def convert_expires_at(expires_at)
      Time.iso8601(expires_at.to_s).to_i
    rescue ArgumentError
      expires_at.to_i
    end
  end
end