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
|
# frozen_string_literal: true
module HTTPX
module Plugins
#
# This plugin adds support for managing an OAuth Session associated with the given session.
#
# The scope of OAuth support is limited to the `client_crendentials` and `refresh_token` grants.
#
# https://gitlab.com/os85/httpx/wikis/OAuth
#
module OAuth
class << self
def load_dependencies(klass)
require_relative "auth/basic"
klass.plugin(:auth)
end
def subplugins
{
retries: OAuthRetries,
}
end
def extra_options(options)
options.merge(auth_header_type: "Bearer")
end
end
SUPPORTED_GRANT_TYPES = %w[client_credentials refresh_token].freeze
SUPPORTED_AUTH_METHODS = %w[client_secret_basic client_secret_post].freeze
# Implements the bulk of functionality and maintains the state associated with the
# management of the the lifecycle of an OAuth session.
class OAuthSession
attr_reader :access_token, :refresh_token
def initialize(
issuer:,
client_id:,
client_secret:,
access_token: nil,
refresh_token: nil,
scope: nil,
audience: nil,
token_endpoint: nil,
grant_type: nil,
token_endpoint_auth_method: nil
)
@issuer = URI(issuer)
@client_id = client_id
@client_secret = client_secret
@token_endpoint = URI(token_endpoint) if token_endpoint
@scope = case scope
when String
scope.split
when Array
scope
end
@audience = audience
@access_token = access_token
@refresh_token = refresh_token
@token_endpoint_auth_method = String(token_endpoint_auth_method) if token_endpoint_auth_method
@grant_type = grant_type || (@refresh_token ? "refresh_token" : "client_credentials")
@access_token = access_token
@refresh_token = refresh_token
unless @token_endpoint_auth_method.nil? || SUPPORTED_AUTH_METHODS.include?(@token_endpoint_auth_method)
raise Error, "#{@token_endpoint_auth_method} is not a supported auth method"
end
return if SUPPORTED_GRANT_TYPES.include?(@grant_type)
raise Error, "#{@grant_type} is not a supported grant type"
end
# returns the URL where to request access and refresh tokens from.
def token_endpoint
@token_endpoint || "#{@issuer}/token"
end
# returns the oauth-documented authorization method to use when requesting a token.
def token_endpoint_auth_method
@token_endpoint_auth_method || "client_secret_basic"
end
def reset!
@access_token = nil
end
# when not available, it uses the +http+ object to request new access and refresh tokens.
def fetch_access_token(http)
return access_token if access_token
load(http)
# always prefer refresh token grant if a refresh token is available
grant_type = @refresh_token ? "refresh_token" : @grant_type
headers = {} # : Hash[String ,String]
form_post = {
"grant_type" => @grant_type,
"scope" => Array(@scope).join(" "),
"audience" => @audience,
}.compact
# auth
case token_endpoint_auth_method
when "client_secret_post"
form_post["client_id"] = @client_id
form_post["client_secret"] = @client_secret
when "client_secret_basic"
headers["authorization"] = Authentication::Basic.new(@client_id, @client_secret).authenticate
end
case grant_type
when "client_credentials"
# do nothing
when "refresh_token"
raise Error, "cannot use the `\"refresh_token\"` grant type without a refresh token" unless refresh_token
form_post["refresh_token"] = refresh_token
end
# POST /token
token_request = http.build_request("POST", token_endpoint, headers: headers, form: form_post)
token_request.headers.delete("authorization") unless token_endpoint_auth_method == "client_secret_basic"
token_response = http.skip_auth_header { http.request(token_request) }
begin
token_response.raise_for_status
rescue HTTPError => e
@refresh_token = nil if e.response.status == 401 && (grant_type == "refresh_token")
raise e
end
payload = token_response.json
@refresh_token = payload["refresh_token"] || @refresh_token
@access_token = payload["access_token"]
end
# TODO: remove this after deprecating the `:oauth_session` option
def merge(other)
obj = dup
case other
when OAuthSession
other.instance_variables.each do |ivar|
val = other.instance_variable_get(ivar)
next unless val
obj.instance_variable_set(ivar, val)
end
when Hash
other.each do |k, v|
obj.instance_variable_set(:"@#{k}", v) if obj.instance_variable_defined?(:"@#{k}")
end
end
obj
end
private
# uses +http+ to fetch for the oauth server metadata.
def load(http)
return if @grant_type && @scope
metadata = http.skip_auth_header { http.get("#{@issuer}/.well-known/oauth-authorization-server").raise_for_status.json }
@token_endpoint = metadata["token_endpoint"]
@scope = metadata["scopes_supported"]
@grant_type = Array(metadata["grant_types_supported"]).find { |gr| SUPPORTED_GRANT_TYPES.include?(gr) }
@token_endpoint_auth_method = Array(metadata["token_endpoint_auth_methods_supported"]).find do |am|
SUPPORTED_AUTH_METHODS.include?(am)
end
nil
end
end
# adds support for the following options:
#
# :oauth_options :: an hash of options to be used during session management.
# check the parameters to initialize the OAuthSession class.
module OptionsMethods
private
def option_oauth_session(value)
warn "DEPRECATION WARNING: option `:oauth_session` is deprecated. " \
"Use `:oauth_options` instead."
case value
when Hash
OAuthSession.new(**value)
when OAuthSession
value
else
raise TypeError, ":oauth_session must be a #{OAuthSession}"
end
end
def option_oauth_options(value)
value = Hash[value] unless value.is_a?(Hash)
value
end
end
module InstanceMethods
attr_reader :oauth_session
protected :oauth_session
def initialize(*)
super
@oauth_session = if @options.oauth_options
OAuthSession.new(**@options.oauth_options)
elsif @options.oauth_session
@oauth_session = @options.oauth_session.dup
end
end
def initialize_dup(other)
super
@oauth_session = other.instance_variable_get(:@oauth_session).dup
end
def oauth_auth(**args)
warn "DEPRECATION WARNING: `#{__method__}` is deprecated. " \
"Use `with(oauth_options: options)` instead."
with(oauth_options: args)
end
# will eagerly negotiate new oauth tokens with the issuer
def refresh_oauth_tokens!
return unless @oauth_session
@oauth_session.reset!
@oauth_session.fetch_access_token(self)
end
# TODO: deprecate
def with_access_token
warn "DEPRECATION WARNING: `#{__method__}` is deprecated. " \
"The session will automatically handle token lifecycles for you."
other_session = dup # : instance
oauth_session = other_session.oauth_session
oauth_session.fetch_access_token(other_session)
other_session
end
private
def generate_auth_token
return unless @oauth_session
@oauth_session.fetch_access_token(self)
end
def dynamic_auth_token?(_)
@oauth_session
end
end
module OAuthRetries
module InstanceMethods
private
def prepare_to_retry(_request, response)
@oauth_session.reset! if @oauth_session
super
end
end
end
end
register_plugin :oauth, OAuth
end
end
|