Class: X::OAuth2Authorization

Inherits:
Object
  • Object
show all
Includes:
CredentialHolder
Defined in:
x-core/lib/x/core/oauth2_authorization.rb

Overview

Authorizes an app to act for a user with the OAuth 2.0 authorization code flow and PKCE

The flow takes two requests to the app: one that sends the user to X to authorize it, and one that X redirects the user back to. The state and code verifier of the first must reach the second, so store them, as in the session, and build the authorization again with them when X redirects back.

Constant Summary collapse

AUTHORIZATION_URL =

The page that asks a user to authorize an app

"https://x.com/i/oauth2/authorize"
DEFAULT_SCOPES =

The scopes that read posts and users, and keep a refresh token to act for the user after the access token expires

%w[tweet.read users.read offline.access].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(client_id:, redirect_uri:, client_secret: nil, scopes: DEFAULT_SCOPES, state: SecureRandom.urlsafe_base64(STATE_BYTES), code_verifier: SimpleOAuth::OAuth2::PKCE.generate.verifier, base_url: Client::DEFAULT_BASE_URL, proxy_url: nil, open_timeout: Client::DEFAULT_OPEN_TIMEOUT, read_timeout: Client::DEFAULT_READ_TIMEOUT, write_timeout: Client::DEFAULT_WRITE_TIMEOUT, keep_alive_timeout: Client::DEFAULT_KEEP_ALIVE_TIMEOUT, debug_output: nil, headers: {}) ⇒ OAuth2Authorization

Initialize an authorization

A new state and code verifier are generated unless they are given. The authorization code is exchanged for tokens at the origin of the base URL given, with the proxy, timeouts, keep-alive timeout, debug output, and headers given, which a client built with #client is given too.

Examples:

Start an authorization

authorization = X::OAuth2Authorization.new(client_id: "id", redirect_uri: "https://example.com/callback")

Parameters:

  • client_id (String) —

    the OAuth 2.0 client ID of the app

  • redirect_uri (String) —

    the URL X redirects the user back to, as registered for the app

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

    the client secret of a confidential app, or nil for a public client

  • scopes (Array<String>) (defaults to: DEFAULT_SCOPES) —

    the scopes to ask the user for; offline.access keeps a refresh token

  • state (String) (defaults to: SecureRandom.urlsafe_base64(STATE_BYTES)) —

    the state, as stored when the user was sent to X

  • code_verifier (String) (defaults to: SimpleOAuth::OAuth2::PKCE.generate.verifier) —

    the PKCE code verifier, as stored when the user was sent to X

  • base_url (String) (defaults to: Client::DEFAULT_BASE_URL) —

    the base URL of the client the authorization builds, at whose origin the code is exchanged, as the client refreshes its tokens, so that an authorization pointed at another host, such as a test server, exchanges the code there

  • proxy_url (String, URI::Generic, nil) (defaults to: nil) —

    the proxy URL for the token request

  • open_timeout (Integer, Float, nil) (defaults to: Client::DEFAULT_OPEN_TIMEOUT) —

    the timeout for opening connections in seconds, or nil for none

  • read_timeout (Integer, Float, nil) (defaults to: Client::DEFAULT_READ_TIMEOUT) —

    the timeout for reading responses in seconds, or nil for none

  • write_timeout (Integer, Float, nil) (defaults to: Client::DEFAULT_WRITE_TIMEOUT) —

    the timeout for writing requests in seconds, or nil for none

  • keep_alive_timeout (Integer, Float) (defaults to: Client::DEFAULT_KEEP_ALIVE_TIMEOUT) —

    the time to keep a connection open for the next request to the same host, in seconds, which a proxy that closes idle connections sooner than X does may need lowered

  • debug_output (IO, #<<, nil) (defaults to: nil) —

    the IO object for debug output, or anything else that takes a String with <<, such as a StringIO. It is written every request and response whole, in the clear: the Authorization header, the client secret a token request sends, and the tokens a token response holds. Send it to a file you control while debugging, never to a log that is shipped elsewhere, and leave it nil in production.

  • headers (Hash{String, Symbol => String}) (defaults to: {}) —

    the headers the code is exchanged with, beside the User-Agent of the gem, which one of them of that name replaces, as the headers of a client are sent, such as one a gateway the base URL names requires; an Authorization header is not sent, since the exchange carries its own credentials

Raises:

  • (ArgumentError) —

    if the client ID or redirect URI is nil or empty, or the client secret is empty, which would send the user to X with a URL it refuses

  • (ArgumentError) —

    if the scopes are not an Array of Strings that each name a scope, as a String that holds a space, which names two, does not

  • (ArgumentError) —

    if the state is nil or empty, which would accept the redirect of any authorization

  • (ArgumentError) —

    if the code verifier is not 43 to 128 unreserved characters

  • (ArgumentError) —

    if the base URL is not an absolute http or https URL with no user, password, query, or fragment

  • (ArgumentError) —

    if a timeout is neither a finite number of seconds of at least 0 nor nil, or the keep-alive timeout is not a finite number of seconds of at least 0

  • (ArgumentError) —

    if the headers are not a Hash that names each header with a String or a Symbol and gives it a String



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'x-core/lib/x/core/oauth2_authorization.rb', line 150

def initialize(client_id:, redirect_uri:, client_secret: nil, scopes: DEFAULT_SCOPES, state: SecureRandom.urlsafe_base64(STATE_BYTES),
  code_verifier: SimpleOAuth::OAuth2::PKCE.generate.verifier, base_url: Client::DEFAULT_BASE_URL, proxy_url: nil,
  open_timeout: Client::DEFAULT_OPEN_TIMEOUT, read_timeout: Client::DEFAULT_READ_TIMEOUT,
  write_timeout: Client::DEFAULT_WRITE_TIMEOUT, keep_alive_timeout: Client::DEFAULT_KEEP_ALIVE_TIMEOUT, debug_output: nil, headers: {})
  validate!(client_id:, redirect_uri:, client_secret:, scopes:, state:)
  @client_id = client_id
  @client_secret = client_secret
  @redirect_uri = redirect_uri
  @scopes = CredentialValidator.frozen_scopes(scopes)
  @state = state
  @pkce = SimpleOAuth::OAuth2::PKCE.new(verifier: code_verifier)
  @code_verifier = code_verifier
  @settings = {base_url: SettingValidator.base_url!(base_url), proxy_url:, open_timeout:, read_timeout:, write_timeout:, keep_alive_timeout:, debug_output:, headers: SettingValidator.headers!(headers)}
  @connection = Connection.new(**@settings.except(:base_url, :headers))
end

Instance Attribute Details

#client_id ⇒ String (readonly)

The OAuth 2.0 client ID of the app

Examples:

Get the client ID

authorization.client_id

Returns:

  • (String) —

    the client ID



71
72
73
# File 'x-core/lib/x/core/oauth2_authorization.rb', line 71

def client_id
  @client_id
end

#code_verifier ⇒ String (readonly)

The PKCE code verifier, to store until X redirects back

Examples:

Store the code verifier in the session

session[:code_verifier] = authorization.code_verifier

Returns:

  • (String) —

    the code verifier



103
104
105
# File 'x-core/lib/x/core/oauth2_authorization.rb', line 103

def code_verifier
  @code_verifier
end

#redirect_uri ⇒ String (readonly)

The URL X redirects the user back to, as registered for the app

Examples:

Get the redirect URI

authorization.redirect_uri

Returns:

  • (String) —

    the redirect URI



78
79
80
# File 'x-core/lib/x/core/oauth2_authorization.rb', line 78

def redirect_uri
  @redirect_uri
end

#scopes ⇒ Array<String> (readonly)

The scopes the app asks the user for

They are a frozen copy of the ones given, so that changing the Array the authorization was given never changes what it asks for, or the scopes of the tokens it builds when X names none.

Examples:

Get the scopes

authorization.scopes # => ["tweet.read", "users.read", "offline.access"]

Returns:

  • (Array<String>) —

    the scopes, frozen



89
90
91
# File 'x-core/lib/x/core/oauth2_authorization.rb', line 89

def scopes
  @scopes
end

#state ⇒ String (readonly)

The value that ties the redirect back from X to this authorization

Examples:

Store the state in the session

session[:state] = authorization.state

Returns:

  • (String) —

    the state



96
97
98
# File 'x-core/lib/x/core/oauth2_authorization.rb', line 96

def state
  @state
end

Instance Method Details

#client(callback, **options) ⇒ Client

Exchange the code of the redirect back from X for a client

The options are checked before the code is exchanged, since X accepts it once, so an option the client refuses, such as a misspelled keyword, raises before the code is spent rather than after, with the tokens it was exchanged for lost.

The save_tokens of the client is passed the OAuth2Tokens of the exchange before the client is returned, as it is passed those of each refresh after, so that a callable that stores them stores every refresh token X issues, the first among them; a refresh token held by the client alone would be lost with it, and the user would have to authorize the app again. Without offline.access, which issues no refresh token, it is passed tokens whose refresh token is nil, so it stores the access token that acts for the user until it expires. A callable that raises, as one whose storage is briefly down may, raises TokenReportFailed, which holds the client and the tokens, so neither is lost with the code.

The code is exchanged at the origin of the base URL of the client, the base_url of the options or else that of the authorization, as the client refreshes its tokens there, and through the proxy, with the timeouts, keep-alive timeout, debug output, and headers of the client, so a client given a proxy reaches X through it from the first request of its tokens.

Examples:

Act for the user who authorized the app, storing the refresh token of the exchange and of each refresh

client = authorization.client(request.url, save_tokens: ->(tokens) { store.save(tokens.refresh_token) })

Parameters:

  • callback (String, Hash) —

    the redirect back from X: its URL, its query string, or its query parameters

  • options (Hash) —

    other options of Client#initialize, such as save_tokens, which it is built with beside the base URL, proxy, timeouts, keep-alive timeout, debug output, and headers of the authorization, and in place of them

Returns:

  • (Client) —

    a client with the user's credentials

Raises:

  • (ArgumentError) —

    if an option is one Client#initialize refuses, or a credential or an authenticator, which the client is given by the exchange of the code

  • (AuthorizationDenied) —

    if the user denied the app, the state does not match, or the redirect is not a valid URL

  • (AuthorizationError) —

    if X refuses the code, with the response that refused it

  • (HTTPError, InvalidResponse) —

    if the token endpoint limits the rate of the request or fails to answer, as a server error, a redirect, or the page of a proxy says

  • (TokenReportFailed) —

    if save_tokens raises for the tokens of the exchange, with the client and tokens



246
247
248
249
250
251
252
253
# File 'x-core/lib/x/core/oauth2_authorization.rb', line 246

def client(callback, **options) # steep:ignore DifferentMethodParameterKind
  given = options.keys & CREDENTIALS
  raise ArgumentError, format(CREDENTIALS_GIVEN_MESSAGE, given.join(", ")) unless given.empty?

  checked = Client.new(**@settings, **options) # refuses an option before the code, which X accepts once, is spent
  tokens = tokens_from(exchange(callback, checked.base_url, checked.headers, connection_for(options)))
  client_of(tokens, options).tap { |client| report_exchange(client, tokens) }
end

#inspect ⇒ String

Summarize the authorization for the console without revealing its secrets

Examples:

Inspect an authorization

authorization.inspect # => #<X::OAuth2Authorization client_id="id" redirect_uri="https://example.com/callback" ...>

Returns:

  • (String) —

    the class name, client ID, redirect URI, and scopes



172
173
174
# File 'x-core/lib/x/core/oauth2_authorization.rb', line 172

def inspect
  "#<#{self.class} client_id=#{client_id.inspect} redirect_uri=#{redirect_uri.inspect} scopes=#{scopes}>"
end

#tokens(callback) ⇒ OAuth2Tokens

Exchange the code of the redirect back from X for the tokens of the user

An authorization code works once, so call this, or #client, once for each redirect.

The tokens are the OAuth2Tokens a client passes save_tokens and reads from load_tokens, which are stored for each user, so they leave out the client ID, and the client secret of a confidential client, which are the app's and kept once, apart from them; pass them beside the tokens to a client built of them, which refreshes the access token with them.

Examples:

Store the tokens of the user

store.save(authorization.tokens(request.url))

Build the client of a confidential app from the tokens it stored

X::Client.new(client_id: ENV.fetch("X_CLIENT_ID"), client_secret: ENV.fetch("X_CLIENT_SECRET"), **store.load.to_h)

Parameters:

  • callback (String, Hash) —

    the redirect back from X: its URL, its query string, or its query parameters

Returns:

  • (OAuth2Tokens) —

    the tokens: the access token, the refresh token, and the expiration time; an authorization without offline.access issues no refresh token, so its tokens hold none, and a client built of them acts for the user until the access token expires, and cannot authenticate as the app

Raises:

  • (AuthorizationDenied) —

    if the user denied the app, the state does not match, or the redirect is not a valid URL

  • (AuthorizationError) —

    if X refuses the code, with the response that refused it

  • (HTTPError, InvalidResponse) —

    if the token endpoint limits the rate of the request or fails to answer, as a server error, a redirect, or the page of a proxy says



209
# File 'x-core/lib/x/core/oauth2_authorization.rb', line 209

def tokens(callback) = tokens_from(exchange(callback, base_url, @settings.fetch(:headers)))

#url ⇒ String

The page on X that asks the user to authorize the app, to redirect the user to

Examples:

Send the user to X

redirect_to authorization.url

Returns:

  • (String) —

    the authorization URL, with the state and PKCE code challenge



182
183
184
# File 'x-core/lib/x/core/oauth2_authorization.rb', line 182

def url
  oauth2_client(base_url).authorization_url(redirect_uri:, pkce: @pkce, state:, scope: scopes)
end