Class: SimpleOAuth::OAuth2::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/simple_oauth/oauth2/client.rb,
sig/simple_oauth/oauth2.rbs

Overview

An OAuth 2.0 client that builds authorization URLs and endpoint requests

Constant Summary collapse

AUTH_METHODS =

Client authentication methods for confidential clients (RFC 6749 Section 2.3.1)

Returns:

  • (Array[Symbol])
%i[client_secret_basic client_secret_post].freeze
FORM_CONTENT_TYPE =

The content type of every request body

Returns:

  • (String)
"application/x-www-form-urlencoded"
EMPTY_STATE =

The error message for a state that is given but empty

Returns:

  • (String)
"The state must not be empty"
UNPROTECTED =

The error message for an authorization request that nothing ties to its response

Returns:

  • (String)
"Pass a pkce, or a state, so that the authorization response can be tied to this request"

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(client_id:, client_secret: nil, authorization_endpoint: nil, token_endpoint: nil, revocation_endpoint: nil, auth_method: :client_secret_basic) ⇒ Client

Initialize a new client

Examples:

A confidential client

SimpleOAuth::OAuth2::Client.new(client_id: "s6BhdRkqt3", client_secret: "gX1fBat3bV",
  token_endpoint: "https://example.com/token")

Parameters:

  • client_id (String)

    the client identifier

  • client_secret (String, nil) (defaults to: nil)

    the client secret, or nil for a public client; an empty secret is no secret, so a client given one is public

  • authorization_endpoint (String, nil) (defaults to: nil)

    the authorization endpoint URL

  • token_endpoint (String, nil) (defaults to: nil)

    the token endpoint URL

  • revocation_endpoint (String, nil) (defaults to: nil)

    the revocation endpoint URL

  • auth_method (Symbol) (defaults to: :client_secret_basic)

    how a confidential client authenticates: client_secret_basic or client_secret_post

Raises:

  • (ArgumentError)

    if the authentication method is unknown



94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/simple_oauth/oauth2/client.rb', line 94

def initialize(client_id:, client_secret: nil, authorization_endpoint: nil, token_endpoint: nil,
  revocation_endpoint: nil, auth_method: :client_secret_basic)
  raise ArgumentError, "Unknown auth_method: #{auth_method.inspect}" unless AUTH_METHODS.include?(auth_method)

  @client_id = client_id
  @client_secret = client_secret
  @authorization_endpoint = authorization_endpoint
  @token_endpoint = token_endpoint
  @revocation_endpoint = revocation_endpoint
  @auth_method = auth_method
  freeze
end

Instance Attribute Details

#auth_methodSymbol (readonly)

How a confidential client authenticates with its secret

Examples:

client.auth_method # => :client_secret_basic

Returns:

  • (Symbol)

    the authentication method



78
79
80
# File 'lib/simple_oauth/oauth2/client.rb', line 78

def auth_method
  @auth_method
end

#authorization_endpointString? (readonly)

The authorization endpoint URL

Examples:

client.authorization_endpoint # => "https://example.com/authorize"

Returns:

  • (String, nil)

    the authorization endpoint



54
55
56
# File 'lib/simple_oauth/oauth2/client.rb', line 54

def authorization_endpoint
  @authorization_endpoint
end

#client_idString (readonly)

The client identifier

Examples:

client.client_id # => "s6BhdRkqt3"

Returns:

  • (String)

    the client identifier



38
39
40
# File 'lib/simple_oauth/oauth2/client.rb', line 38

def client_id
  @client_id
end

#client_secretString? (readonly)

The client secret, or nil for a public client

Examples:

client.client_secret # => "gX1fBat3bV"

Returns:

  • (String, nil)

    the client secret



46
47
48
# File 'lib/simple_oauth/oauth2/client.rb', line 46

def client_secret
  @client_secret
end

#revocation_endpointString? (readonly)

The revocation endpoint URL

Examples:

client.revocation_endpoint # => "https://example.com/revoke"

Returns:

  • (String, nil)

    the revocation endpoint



70
71
72
# File 'lib/simple_oauth/oauth2/client.rb', line 70

def revocation_endpoint
  @revocation_endpoint
end

#token_endpointString? (readonly)

The token endpoint URL

Examples:

client.token_endpoint # => "https://example.com/token"

Returns:

  • (String, nil)

    the token endpoint



62
63
64
# File 'lib/simple_oauth/oauth2/client.rb', line 62

def token_endpoint
  @token_endpoint
end

Instance Method Details

#authenticated(params) ⇒ Array(Hash, Hash)

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

The headers and form parameters that carry the client's credentials

A confidential client authenticates with HTTP Basic or in the body, and a public client identifies itself with its client_id alone (RFC 6749 Section 2.3.1).

Parameters:

  • params (Hash)

    the form parameters

Returns:

  • (Array(Hash, Hash))

    the headers and the form parameters



261
262
263
264
265
266
267
268
# File 'lib/simple_oauth/oauth2/client.rb', line 261

def authenticated(params)
  headers = {"Content-Type" => FORM_CONTENT_TYPE, "Accept" => "application/json"}
  secret = client_secret unless public?
  return [headers, params.merge(client_id:)] if secret.nil?
  return [headers, params.merge(client_id:, client_secret: secret)] if auth_method.eql?(:client_secret_post)

  [headers.merge("Authorization" => basic_authorization(secret)), params]
end

#authorization_code_request(code:, redirect_uri:, code_verifier: nil, params: {}) ⇒ Request

Build the request that exchanges an authorization code for a token

Examples:

client.authorization_code_request(code: "SplxlOBeZQQYbYS6WxSbIA", redirect_uri: "https://app.example/cb",
  code_verifier: pkce.verifier)

Parameters:

  • code (String)

    the authorization code

  • redirect_uri (String)

    the redirect URI sent in the authorization URL

  • code_verifier (String, nil) (defaults to: nil)

    the PKCE verifier, if the authorization URL sent a challenge

  • params (Hash) (defaults to: {})

    additional form parameters, which override the ones the client sends itself

  • code: (String)
  • redirect_uri: (String)
  • code_verifier: (String, nil) (defaults to: nil)
  • params: (extra_params) (defaults to: {})

Returns:

Raises:

  • (ArgumentError)

    if the client has no token endpoint



165
166
167
# File 'lib/simple_oauth/oauth2/client.rb', line 165

def authorization_code_request(code:, redirect_uri:, code_verifier: nil, params: {})
  token_request({grant_type: "authorization_code", code:, redirect_uri:, code_verifier:}, params)
end

#authorization_url(redirect_uri:, pkce:, state: nil, scope: nil, params: {}) ⇒ String

Build the URL where the user authorizes the client (RFC 6749 Section 4.1.1)

The pkce is named rather than defaulted because only the caller can keep the verifier to send with the code. OAuth 2.1 asks every client for one, so pass pkce: nil to leave it out, for an authorization server that rejects the challenge parameters.

A state is what OAuth 2.0 ties the response to the request with. A PKCE challenge does that too, so with one the state is free to carry application state, or to be left out.

Examples:

client.authorization_url(redirect_uri: "https://app.example/cb", state: "xyz",
  scope: %w[tweet.read users.read], pkce: SimpleOAuth::OAuth2::PKCE.generate)

Parameters:

  • redirect_uri (String)

    where the authorization server returns the user

  • pkce (PKCE, nil)

    the PKCE challenge to send, or nil to send none

  • state (String, nil) (defaults to: nil)

    an unguessable value the authorization server returns with the code, which AuthorizationResponse.parse checks

  • scope (String, Array<String>, nil) (defaults to: nil)

    the requested scope

  • params (Hash) (defaults to: {})

    additional query parameters, which override the ones the client sends itself, whether their keys are Strings or Symbols

  • redirect_uri: (String)
  • pkce: (PKCE, nil)
  • state: (String, nil) (defaults to: nil)
  • scope: (scope) (defaults to: nil)
  • params: (extra_params) (defaults to: {})

Returns:

  • (String)

    the authorization URL

Raises:

  • (ArgumentError)

    if the state is given but empty, if neither a pkce nor a state is given, or if the client has no authorization endpoint



143
144
145
146
147
148
149
150
151
# File 'lib/simple_oauth/oauth2/client.rb', line 143

def authorization_url(redirect_uri:, pkce:, state: nil, scope: nil, params: {})
  validate_protection!(pkce, state)
  url = endpoint(authorization_endpoint, :authorization_endpoint)
  query = {response_type: "code", client_id:, redirect_uri:, scope: scope_value(scope), state:,
           code_challenge: pkce&.challenge, code_challenge_method: pkce&.challenge_method}
  # Symbolize the caller's keys so that a String key overrides rather than repeating a parameter
  query = query.merge(params.transform_keys(&:to_sym)).compact
  "#{url}#{url.include?("?") ? "&" : "?"}#{URI.encode_www_form(query)}"
end

#basic_authorization(secret) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

The HTTP Basic credentials, form-encoded first per RFC 6749 Section 2.3.1

Parameters:

  • secret (String)

    the client secret

Returns:

  • (String)

    the Authorization header value



275
276
277
278
279
# File 'lib/simple_oauth/oauth2/client.rb', line 275

def basic_authorization(secret)
  credentials = [client_id, secret].map { |value| URI.encode_www_form_component(value) }.join(":")
  # "m0" is Base64 with no line breaks
  "Basic #{[credentials].pack("m0")}"
end

#client_credentials_request(scope: nil, params: {}) ⇒ Request

Build the request for a token that acts as the client itself

Examples:

client.client_credentials_request

Parameters:

  • scope (String, Array<String>, nil) (defaults to: nil)

    the requested scope

  • params (Hash) (defaults to: {})

    additional form parameters, which override the ones the client sends itself

  • scope: (scope) (defaults to: nil)
  • params: (extra_params) (defaults to: {})

Returns:

Raises:

  • (ArgumentError)

    if the client is public or has no token endpoint



192
193
194
195
196
# File 'lib/simple_oauth/oauth2/client.rb', line 192

def client_credentials_request(scope: nil, params: {})
  raise ArgumentError, "The client credentials grant requires a client secret" if public?

  token_request({grant_type: "client_credentials", scope: scope_value(scope)}, params)
end

#endpoint(url, name) ⇒ String

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

An endpoint URL, which must be configured

Parameters:

  • url (String, nil)

    the endpoint URL

  • name (Symbol)

    the endpoint name for the error message

Returns:

  • (String)

    the endpoint URL

Raises:

  • (ArgumentError)

    if the endpoint is not configured



302
303
304
# File 'lib/simple_oauth/oauth2/client.rb', line 302

def endpoint(url, name)
  url || raise(ArgumentError, "The client has no #{name}")
end

#form_request(url, params, extra) ⇒ Request

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Build an authenticated form POST

The caller's own parameters are merged last, so that they can carry an extension such as the resource of RFC 8707, and can replace anything the client would send itself.

Parameters:

  • url (String)

    the endpoint URL

  • params (Hash)

    the form parameters

  • extra (Hash)

    the caller's own form parameters

Returns:



246
247
248
249
250
251
# File 'lib/simple_oauth/oauth2/client.rb', line 246

def form_request(url, params, extra)
  headers, params = authenticated(params)
  # Symbolize the caller's keys so that a String key overrides rather than repeating a parameter
  body = params.merge(extra.transform_keys(&:to_sym)).compact
  Request.new(method: "POST", url:, headers:, body: URI.encode_www_form(body))
end

#public?Boolean

Check whether the client is public, meaning it has no secret

A secret that is empty is no secret, so a client holding one cannot authenticate with it and identifies itself with its client_id alone.

Examples:

client.public? # => false

Returns:

  • (Boolean)

    true if the client has no secret



116
117
118
# File 'lib/simple_oauth/oauth2/client.rb', line 116

def public?
  client_secret.to_s.empty?
end

#refresh_token_request(refresh_token:, scope: nil, params: {}) ⇒ Request

Build the request that exchanges a refresh token for a new token

Examples:

client.refresh_token_request(refresh_token: "tGzv3JOkF0XG5Qx2TlKWIA")

Parameters:

  • refresh_token (String)

    the refresh token

  • scope (String, Array<String>, nil) (defaults to: nil)

    a narrower scope to request

  • params (Hash) (defaults to: {})

    additional form parameters, which override the ones the client sends itself

  • refresh_token: (String)
  • scope: (scope) (defaults to: nil)
  • params: (extra_params) (defaults to: {})

Returns:

Raises:

  • (ArgumentError)

    if the client has no token endpoint



179
180
181
# File 'lib/simple_oauth/oauth2/client.rb', line 179

def refresh_token_request(refresh_token:, scope: nil, params: {})
  token_request({grant_type: "refresh_token", refresh_token:, scope: scope_value(scope)}, params)
end

#revocation_request(token:, token_type_hint: nil, params: {}) ⇒ Request

Build the request that revokes an access or refresh token (RFC 7009 Section 2.1)

Examples:

client.revocation_request(token: "45ghiukldjahdnhzdauz", token_type_hint: "refresh_token")

Parameters:

  • token (String)

    the token to revoke

  • token_type_hint (String, nil) (defaults to: nil)

    access_token or refresh_token

  • params (Hash) (defaults to: {})

    additional form parameters, which override the ones the client sends itself

  • token: (String)
  • token_type_hint: (String, nil) (defaults to: nil)
  • params: (extra_params) (defaults to: {})

Returns:

  • (Request)

    the revocation request

Raises:

  • (ArgumentError)

    if the client has no revocation endpoint



208
209
210
# File 'lib/simple_oauth/oauth2/client.rb', line 208

def revocation_request(token:, token_type_hint: nil, params: {})
  form_request(endpoint(revocation_endpoint, :revocation_endpoint), {token:, token_type_hint:}, params)
end

#scope_value(scope) ⇒ String?

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Join a list of scopes with spaces

RFC 6749 Appendix A.4 defines a scope as one or more characters, so an empty scope is omitted rather than sent as an empty parameter.

Parameters:

  • scope (String, Array<String>, nil)

    the scope

Returns:

  • (String, nil)

    the space-delimited scope, or nil if there is none



289
290
291
292
293
# File 'lib/simple_oauth/oauth2/client.rb', line 289

def scope_value(scope)
  # Array#join flattens, so a String and an Array of Strings both join correctly, and nil joins to ""
  value = [scope].join(" ")
  value unless value.empty?
end

#token_request(params, extra) ⇒ Request

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Build a request to the token endpoint

Parameters:

  • params (Hash)

    the form parameters

  • extra (Hash)

    the caller's own form parameters

Returns:



232
233
234
# File 'lib/simple_oauth/oauth2/client.rb', line 232

def token_request(params, extra)
  form_request(endpoint(token_endpoint, :token_endpoint), params, extra)
end

#validate_protection!(pkce, state) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Checks that something ties the authorization response to the request

Parameters:

  • pkce (PKCE, nil)

    the PKCE challenge to send, or nil to send none

  • state (String, nil)

    the state to send, or nil to send none

Raises:

  • (ArgumentError)

    if the state is given but empty, or neither is given



221
222
223
224
# File 'lib/simple_oauth/oauth2/client.rb', line 221

def validate_protection!(pkce, state)
  raise ArgumentError, EMPTY_STATE if !state.nil? && state.empty?
  raise ArgumentError, UNPROTECTED if pkce.nil? && state.nil?
end