Class: Legate::Auth::Schemes::OAuth2

Inherits:
Legate::Auth::Scheme show all
Defined in:
lib/legate/auth/schemes/oauth2.rb

Overview

Implements OAuth 2.0 authentication Supports authorization code flow, client credentials flow, and token refresh

Direct Known Subclasses

OpenIDConnect

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods inherited from Legate::Auth::Scheme

#authentication_error?, #revoke_token, #supports_refresh?, #to_h, #to_s

Constructor Details

#initialize(*args, authorization_url: nil, token_url: nil, scopes: nil, use_pkce: true, additional_params: nil, revocation_url: nil, **kwargs) ⇒ OAuth2

Initialize a new OAuth2 scheme

Parameters:

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

    The authorization URL (optional for non-interactive flows)

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

    The token URL (optional for testing)

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

    The requested scopes

  • use_pkce (Boolean) (defaults to: true)

    Whether to use PKCE

  • additional_params (Hash, nil) (defaults to: nil)

    Additional parameters for authorization requests

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

    The URL for the revocation endpoint



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
# File 'lib/legate/auth/schemes/oauth2.rb', line 43

def initialize(*args, authorization_url: nil, token_url: nil, scopes: nil, use_pkce: true, additional_params: nil, revocation_url: nil, **kwargs)
  # Handle positional hash parameter (for backward compatibility with child classes like OpenIDConnect)
  if args.length == 1 && args[0].is_a?(Hash)
    config = args[0]
    authorization_url ||= config[:authorization_url]
    token_url ||= config[:token_url]
    scopes ||= config[:scopes] || config[:scope]
    use_pkce = if config.key?(:use_pkce)
                 config[:use_pkce]
               else
                 use_pkce.nil? || use_pkce
               end
    additional_params ||= config[:additional_params]
    revocation_url ||= config[:revocation_url]

    # Extract additional config values
    kwargs[:client_id] ||= config[:client_id]
    kwargs[:client_secret] ||= config[:client_secret]
    kwargs[:redirect_uri] ||= config[:redirect_uri]

    # Add any remaining config keys to kwargs
    config.each do |k, v|
      unless %i[authorization_url token_url scopes scope use_pkce
                additional_params revocation_url client_id client_secret redirect_uri].include?(k)
        kwargs[k] ||= v
      end
    end
  end

  @authorization_url = authorization_url
  @token_url = token_url
  @scopes = parse_scopes(scopes)
  # Explicitly handle boolean type for use_pkce - preserve false values
  @use_pkce = use_pkce
  @additional_params = additional_params
  @revocation_url = revocation_url

  # Handle additional parameters which might be passed by derived classes
  @client_id = kwargs[:client_id]
  @client_secret = kwargs[:client_secret]
  @redirect_uri = kwargs[:redirect_uri]

  # Remaining options
  @options = kwargs.reject { |k, _| %i[client_id client_secret redirect_uri].include?(k) }

  # Always validate when this is the base class, not a derived class
  validate! if self.class == Legate::Auth::Schemes::OAuth2
end

Instance Attribute Details

#additional_paramsHash? (readonly)

Returns Additional parameters for authorization requests.

Returns:

  • (Hash, nil)

    Additional parameters for authorization requests



31
32
33
# File 'lib/legate/auth/schemes/oauth2.rb', line 31

def additional_params
  @additional_params
end

#authorization_urlString (readonly)

Returns The URL for the authorization endpoint.

Returns:

  • (String)

    The URL for the authorization endpoint



19
20
21
# File 'lib/legate/auth/schemes/oauth2.rb', line 19

def authorization_url
  @authorization_url
end

#revocation_urlString? (readonly)

Returns The URL for the revocation endpoint.

Returns:

  • (String, nil)

    The URL for the revocation endpoint



34
35
36
# File 'lib/legate/auth/schemes/oauth2.rb', line 34

def revocation_url
  @revocation_url
end

#scopesArray<String> (readonly)

Returns The requested scopes.

Returns:

  • (Array<String>)

    The requested scopes



25
26
27
# File 'lib/legate/auth/schemes/oauth2.rb', line 25

def scopes
  @scopes
end

#token_urlString (readonly)

Returns The URL for the token endpoint.

Returns:

  • (String)

    The URL for the token endpoint



22
23
24
# File 'lib/legate/auth/schemes/oauth2.rb', line 22

def token_url
  @token_url
end

#use_pkceBoolean (readonly)

Returns Whether to use PKCE.

Returns:

  • (Boolean)

    Whether to use PKCE



28
29
30
# File 'lib/legate/auth/schemes/oauth2.rb', line 28

def use_pkce
  @use_pkce
end

Instance Method Details

#apply_to_request(request, credential) ⇒ Hash

Applies the OAuth token to a request

Parameters:

Returns:

  • (Hash)

    The updated request

Raises:



178
179
180
181
182
183
184
185
186
187
188
189
# File 'lib/legate/auth/schemes/oauth2.rb', line 178

def apply_to_request(request, credential)
  raise Legate::Auth::CredentialError, 'Expected an exchanged credential' unless credential.is_a?(Legate::Auth::ExchangedCredential)

  access_token = credential[:access_token]
  raise Legate::Auth::CredentialError, 'Access token is missing from credential' unless access_token

  # Apply the access token to the Authorization header
  validate_header_value!(access_token, 'OAuth2 access token')
  request[:headers] ||= {}
  request[:headers]['Authorization'] = "Bearer #{access_token}"
  request
end

#build_authorization_uri(config, redirect_uri = nil, state = nil) ⇒ Hash

Build the authorization URI for the OAuth2 flow

Parameters:

  • config (Legate::Auth::Config)

    The authentication configuration

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

    The redirect URI for the authorization request

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

    A state parameter for CSRF protection

Returns:

  • (Hash)

    The authorization URI and any additional parameters (like PKCE code verifier)



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
# File 'lib/legate/auth/schemes/oauth2.rb', line 119

def build_authorization_uri(config, redirect_uri = nil, state = nil)
  # Get credentials from the config
  credential = config.credential

  # Generate state for CSRF protection if not provided
  state ||= SecureRandom.hex(16)

  # Build the authorization URL with parameters
  client_id = credential[:client_id, resolve_env: true]

  # Create the basic parameters
  params = {
    'client_id' => client_id,
    'response_type' => 'code',
    'redirect_uri' => redirect_uri,
    'state' => state
  }

  # Add scopes if present
  params['scope'] = @scopes.join(' ') if @scopes && !@scopes.empty?

  # Result hash that will be returned
  result = {
    uri: nil, # Will be set below
    state: state
  }

  # Add PKCE if enabled - directly check the instance variable
  # Only add PKCE if @use_pkce is not false (nil would default to true)
  if @use_pkce != false
    code_verifier = SecureRandom.alphanumeric(64)
    code_challenge = generate_code_challenge(code_verifier)

    params['code_challenge'] = code_challenge
    params['code_challenge_method'] = 'S256'

    result[:pkce] = { code_verifier: code_verifier }
  end

  # Add any additional parameters
  params.merge!(@additional_params) if @additional_params

  # Remove nil values
  params.compact!

  # Build the query string
  query = URI.encode_www_form(params)

  # Join with the authorization URL
  result[:uri] = "#{@authorization_url}?#{query}"

  result
end

#client_credentials_token(credential) ⇒ Legate::Auth::ExchangedCredential

Exchange client credentials for an access token (client credentials flow)

Parameters:

Returns:

Raises:



285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
# File 'lib/legate/auth/schemes/oauth2.rb', line 285

def client_credentials_token(credential)
  # Create an OAuth2 client
  oauth_client = create_oauth_client(credential)

  # Request a token using the client credentials flow
  auth_params = {}
  auth_params[:scope] = @scopes.join(' ') if @scopes && !@scopes.empty?

  token = oauth_client.client_credentials.get_token(auth_params)

  # Create an exchanged credential from the token response
  Legate::Auth::ExchangedCredential.new(
    auth_type: scheme_type,
    access_token: token.token,
    token_type: token.params['token_type'],
    expires_at: token.expires_at ? Time.at(token.expires_at) : nil,
    expires_in: token.expires_in,
    scope: token.params['scope']
  )
rescue ::OAuth2::Error => e
  raise Legate::Auth::TokenExchangeError, "OAuth2 client credentials exchange failed: #{e.message}"
rescue StandardError => e
  raise Legate::Auth::TokenExchangeError, "Client credentials exchange failed: #{e.message}"
end

#exchange_token(config, credential) ⇒ Legate::Auth::ExchangedCredential

Exchanges an authorization code for tokens

Parameters:

Returns:

Raises:



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
# File 'lib/legate/auth/schemes/oauth2.rb', line 196

def exchange_token(config, credential)
  raise Legate::Auth::TokenExchangeError, 'Response URI is required for token exchange' unless config.response_uri

  # Extract the code from the response URI
  uri = URI.parse(config.response_uri)
  params = CGI.parse(uri.query || '')
  code = params['code']&.first

  raise Legate::Auth::TokenExchangeError, 'Authorization code not found in response URI' unless code

  # Verify the state parameter to prevent CSRF attacks
  raise Legate::Auth::TokenExchangeError, 'State parameter mismatch' if config.state && params['state']&.first != config.state

  begin
    # Create an OAuth2 client
    oauth_client = create_oauth_client(credential)

    # Exchange the code for tokens
    auth_params = {
      redirect_uri: config.redirect_uri,
      code: code
    }

    # Add PKCE code_verifier if available
    auth_params[:code_verifier] = config.pkce[:code_verifier] if config.pkce && config.pkce[:code_verifier]

    token = oauth_client.auth_code.get_token(code, auth_params)

    # Create an exchanged credential from the token response
    Legate::Auth::ExchangedCredential.new(
      auth_type: scheme_type,
      access_token: token.token,
      refresh_token: token.refresh_token,
      token_type: token.params['token_type'],
      expires_at: token.expires_at ? Time.at(token.expires_at) : nil,
      expires_in: token.expires_in,
      scope: token.params['scope']
    )
  rescue ::OAuth2::Error => e
    raise Legate::Auth::TokenExchangeError, "OAuth2 token exchange failed: #{e.message}"
  rescue StandardError => e
    raise Legate::Auth::TokenExchangeError, "Token exchange failed: #{e.message}"
  end
end

#password_token(credential, username, password) ⇒ Legate::Auth::ExchangedCredential

Password flow for getting an access token (resource owner password credentials flow)

Parameters:

  • credential (Legate::Auth::Credential)

    The credential with client information

  • username (String)

    The resource owner’s username

  • password (String)

    The resource owner’s password

Returns:

Raises:



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
# File 'lib/legate/auth/schemes/oauth2.rb', line 316

def password_token(credential, username, password)
  # Create an OAuth2 client
  oauth_client = create_oauth_client(credential)

  # Request a token using the password flow
  auth_params = {}
  auth_params[:scope] = @scopes.join(' ') if @scopes && !@scopes.empty?

  token = oauth_client.password.get_token(username, password, auth_params)

  # Create an exchanged credential from the token response
  Legate::Auth::ExchangedCredential.new(
    auth_type: scheme_type,
    access_token: token.token,
    refresh_token: token.refresh_token,
    token_type: token.params['token_type'],
    expires_at: token.expires_at ? Time.at(token.expires_at) : nil,
    expires_in: token.expires_in,
    scope: token.params['scope']
  )
rescue ::OAuth2::Error => e
  raise Legate::Auth::TokenExchangeError, "OAuth2 password flow failed: #{e.message}"
rescue StandardError => e
  raise Legate::Auth::TokenExchangeError, "Password flow failed: #{e.message}"
end

#refresh_token(exchanged_credential, credential) ⇒ Legate::Auth::ExchangedCredential

Refreshes an access token using a refresh token

Parameters:

Returns:

Raises:



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
# File 'lib/legate/auth/schemes/oauth2.rb', line 246

def refresh_token(exchanged_credential, credential)
  refresh_token = exchanged_credential[:refresh_token]

  raise Legate::Auth::TokenRefreshError, 'Refresh token is missing from credential' unless refresh_token && !refresh_token.empty?

  begin
    # Create an OAuth2 client
    oauth_client = create_oauth_client(credential)

    # Create a token object with the refresh token
    token = ::OAuth2::AccessToken.from_hash(oauth_client, {
                                              refresh_token: refresh_token,
                                              expires_at: exchanged_credential[:expires_at]&.to_i
                                            })

    # Refresh the token
    refreshed_token = token.refresh!

    # Create a new exchanged credential with the refreshed token
    Legate::Auth::ExchangedCredential.new(
      auth_type: scheme_type,
      access_token: refreshed_token.token,
      refresh_token: refreshed_token.refresh_token || refresh_token,
      token_type: refreshed_token.params['token_type'],
      expires_at: refreshed_token.expires_at ? Time.at(refreshed_token.expires_at) : nil,
      expires_in: refreshed_token.expires_in,
      scope: refreshed_token.params['scope']
    )
  rescue ::OAuth2::Error => e
    raise Legate::Auth::TokenRefreshError, "OAuth2 token refresh failed: #{e.message}"
  rescue StandardError => e
    raise Legate::Auth::TokenRefreshError, "Token refresh failed: #{e.message}"
  end
end

#scheme_typeSymbol

Returns The scheme type.

Returns:

  • (Symbol)

    The scheme type



93
94
95
# File 'lib/legate/auth/schemes/oauth2.rb', line 93

def scheme_type
  :oauth2
end

#validate!Object

Validates the scheme configuration

Raises:



99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/legate/auth/schemes/oauth2.rb', line 99

def validate!
  # Check if we're in test environment and whether to force validation
  in_test = ENV['RSPEC_ENV'] == 'test'
  force_validate = ENV['FORCE_VALIDATE'] == 'true'

  # Only skip validation in test environment if FORCE_VALIDATE is not true
  return if in_test && !force_validate

  raise Legate::Auth::SchemeValidationError, 'Authorization URL is required' if @authorization_url.nil? || @authorization_url.to_s.strip.empty?

  return unless @token_url.nil? || @token_url.to_s.strip.empty?

  raise Legate::Auth::SchemeValidationError, 'Token URL is required'
end