Class: X::OAuth2Authenticator

Inherits:
Authenticator show all
Includes:
OAuth2Refresh
Defined in:
x-core/lib/x/core/oauth2_authenticator.rb

Overview

Handles OAuth 2.0 authentication, refreshing the access token when it expires

X issues a new refresh token with each access token and accepts a refresh token once, so an authenticator refreshes under a lock, and the authenticator of a client passes the tokens each refresh issued, as OAuth2Tokens, to the save_tokens of that client and of each copy of it that shares the authenticator, so that they can be stored.

Processes that share the tokens of a user, storing each refresh with save_tokens, read the store with load_tokens, which a refresh calls under its lock before it refreshes: X accepts a refresh token once, and a process that refreshed with the one another had already spent would be refused. The tokens a refresh takes from the store are not passed to save_tokens, since they came from it.

X issues no refresh token for an authorization without the offline.access scope, so an authenticator built without one authenticates as the user until its access token expires, and refreshes nothing: a request sent with an access token that expired is sent as it is, for the API to reject with Unauthorized, and one the API rejects is not sent again.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(client_id:, access_token:, refresh_token: nil, client_secret: nil, expires_at: nil, scopes: nil, load_tokens: nil) ⇒ OAuth2Authenticator

Initialize a new OAuth 2.0 authenticator

Examples:

Create an authenticator

authenticator = X::OAuth2Authenticator.new(
  client_id: "id",
  client_secret: "secret",
  access_token: "token",
  refresh_token: "refresh"
)

Share the tokens of a user among processes, storing each refresh and reading the store before one

authenticator = X::OAuth2Authenticator.new(client_id: "id", **store.load(user).to_h,
  load_tokens: -> { store.load(user) })
client = X::Client.new(authenticator:, save_tokens: ->(tokens) { store.save(user, tokens) })

Parameters:

  • client_id (String) —

    the OAuth 2.0 client ID

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

    the OAuth 2.0 client secret, or nil for a public client, which sends its client ID in the body of a refresh instead of authenticating with a secret

  • access_token (String) —

    the OAuth 2.0 access token

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

    the OAuth 2.0 refresh token, or nil for an access token issued without the offline.access scope, which the authenticator cannot refresh

  • expires_at (Time, nil) (defaults to: nil) —

    the expiration time of the access token

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

    the scopes X granted the access token, or nil if they are not known

  • load_tokens (#call, nil) (defaults to: nil) —

    a callable that takes no arguments and returns the OAuth2Tokens in the storage the tokens of the user are shared through, or nil for none there, which a refresh reads first, as the load_tokens of X::Client#initialize describes; nil reads the load_tokens of a client that authenticates with the authenticator instead

Raises:

  • (ArgumentError) —

    if the client ID or access token is nil or empty, the refresh token or client secret is empty, the expiration time is neither a Time nor nil, the scopes are neither an Array of Strings that each name a scope nor nil, or load_tokens is neither nil nor responds to call



97
98
99
100
101
102
103
104
105
106
107
# File 'x-core/lib/x/core/oauth2_authenticator.rb', line 97

def initialize(client_id:, access_token:, refresh_token: nil, client_secret: nil, expires_at: nil, scopes: nil, load_tokens: nil)
  CredentialValidator.validate_required!({client_id:, access_token:}, {refresh_token:, client_secret:, expires_at:, scopes:})
  initialize_refresh(load_tokens)
  @client_id = client_id
  @client_secret = client_secret
  @access_token = access_token
  @refresh_token = refresh_token
  @expires_at, @scopes = expires_at, CredentialValidator.frozen_scopes(scopes)
  @connection, @token_url, @token_headers = Connection.new, TOKEN_URL, {}
  @clients = ObjectSpace::WeakMap.new
end

Instance Attribute Details

#client_id ⇒ String (readonly)

The OAuth 2.0 client ID

Examples:

Get the client ID

authenticator.client_id

Returns:

  • (String) —

    the client ID



50
51
52
# File 'x-core/lib/x/core/oauth2_authenticator.rb', line 50

def client_id
  @client_id
end

#expires_at ⇒ Time? (readonly)

The expiration time of the access token

Examples:

Get the expiration time

authenticator.expires_at

Returns:

  • (Time, nil) —

    the expiration time



56
57
58
# File 'x-core/lib/x/core/oauth2_authenticator.rb', line 56

def expires_at
  @expires_at
end

#scopes ⇒ Array<String>? (readonly)

The scopes X granted the access token, as last refreshed

A refresh that names no scopes keeps those the authenticator held, as OAuth 2.0 has it.

Examples:

Check that the user let the app post

authenticator.scopes&.include?("tweet.write")

Returns:

  • (Array<String>, nil) —

    the scopes, frozen, or nil if they are not known



65
66
67
# File 'x-core/lib/x/core/oauth2_authenticator.rb', line 65

def scopes
  @scopes
end

Instance Method Details

#headers(_request) ⇒ Hash{String => String}

Generate the authentication header, refreshing an expired token first

An authenticator that holds no refresh token sends an access token that expired as it is, for the API to reject.

Examples:

Get the header

authenticator.headers(request)

Parameters:

  • _request (#http_method, #uri, #body, #[], nil) —

    the request, which a bearer token does not sign

Returns:

  • (Hash{String => String}) —

    the authentication header

Raises:

  • (AuthorizationError) —

    if the token has expired and X refuses to refresh 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 a refresh, with the tokens



122
123
124
125
# File 'x-core/lib/x/core/oauth2_authenticator.rb', line 122

def headers(_request)
  refresh_expired_token(connection)
  {AUTHENTICATION_HEADER => "Bearer #{access_token}"}
end

#inspect ⇒ String

Summarize the authenticator for the console without revealing credentials

Examples:

Inspect an authenticator

authenticator.inspect # => #<X::OAuth2Authenticator client_id="id" expires_at=nil>

Returns:

  • (String) —

    the class name, client ID, and expiration time



133
# File 'x-core/lib/x/core/oauth2_authenticator.rb', line 133

def inspect = "#<#{self.class} client_id=#{client_id.inspect} expires_at=#{expires_at.inspect}>"

#refresh! ⇒ OAuth2Tokens

Refresh the access token using the refresh token

The authenticator holds the new tokens once it returns, and the authenticator of a client has passed them to the save_tokens of the clients that share it. The tokens it returns are those of this refresh, frozen, the same object save_tokens is passed, so they are a set that belongs together, whatever refreshes follow on other threads.

A refresh reads the tokens in storage first, with load_tokens, and refreshes with the refresh token there when it is another. When X refuses the refresh for a refresh token another process spent, and the storage holds another, the tokens there are returned in place of an error, and are not passed to save_tokens.

A save_tokens that raises, as one whose storage is briefly down may, raises TokenReportFailed once each has been passed the tokens, which holds them, since the refresh token they replaced is spent and the authenticator holds them alone, with the error save_tokens raised as its cause.

Examples:

Refresh the tokens and store them

store.save(**authenticator.refresh!.to_h)

Returns:

  • (OAuth2Tokens) —

    the tokens the refresh issued, or those it took from storage in place of a refusal

Raises:

  • (UnsupportedOperation) —

    if the authenticator holds no refresh token, before any request

  • (AuthorizationError) —

    if X refuses to refresh the token

  • (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 refresh, with the tokens



171
172
173
174
175
176
177
178
179
180
# File 'x-core/lib/x/core/oauth2_authenticator.rb', line 171

def refresh!
  raise UnsupportedOperation, NO_REFRESH_TOKEN unless refresh_token

  tokens = @mutex.synchronize do
    adopt_stored_tokens
    refresh(connection, @token_headers)
  end
  report_refresh(tokens, nil)
  tokens
end

#token_expired? ⇒ Boolean

Check if the access token has expired or will expire soon

Examples:

Check expiration

authenticator.token_expired?

Returns:

  • (Boolean) —

    true if the token has expired or will expire within the buffer period



141
142
143
144
145
# File 'x-core/lib/x/core/oauth2_authenticator.rb', line 141

def token_expired?
  return false if expires_at.nil?

  Time.now >= expires_at - EXPIRATION_BUFFER
end