Class: Legate::Auth::Schemes::OpenIDConnect

Inherits:
OAuth2 show all
Defined in:
lib/legate/auth/schemes/openid_connect.rb

Overview

Implements OpenID Connect authentication Extends OAuth2 with OpenID Connect specific features

Instance Attribute Summary collapse

Attributes inherited from OAuth2

#additional_params, #revocation_url, #scopes, #token_url, #use_pkce

Instance Method Summary collapse

Methods inherited from OAuth2

#apply_to_request, #client_credentials_token, #password_token, #refresh_token

Methods inherited from Legate::Auth::Scheme

#apply_to_request, #authentication_error?, #refresh_token, #revoke_token, #supports_refresh?, #to_s

Constructor Details

#initialize(first_arg = nil, authorization_url: nil, token_url: nil, discovery_url: nil, jwks_url: nil, userinfo_url: nil, scopes: nil, use_pkce: true, additional_params: nil, revocation_url: nil, client_id: nil, client_secret: nil, redirect_uri: nil, **kwargs) ⇒ OpenIDConnect

Initialize a new OpenID Connect scheme

Parameters:

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

    The authorization URL

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

    The token URL

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

    The URL for the discovery document (optional if endpoints provided)

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

    The URL for the JWKS document (optional if discovery URL provided)

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

    The URL for the userinfo endpoint (optional)

  • 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

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

    The client ID

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

    The client secret

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

    The redirect URI

  • kwargs (Hash)

    Additional options to pass to the OAuth2 parent class

  • config (Hash)

    A config hash containing all options (alternative to individual parameters)



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

def initialize(first_arg = nil, authorization_url: nil, token_url: nil, discovery_url: nil,
               jwks_url: nil, userinfo_url: nil, scopes: nil, use_pkce: true,
               additional_params: nil, revocation_url: nil, client_id: nil,
               client_secret: nil, redirect_uri: nil, **kwargs)
  # Handle direct hash configuration in first_arg or config param
  config = first_arg if first_arg.is_a?(Hash)

  if config.is_a?(Hash)
    # Extract OpenID Connect specific properties from config
    @discovery_url = config[:discovery_url] || config[:provider_uri] && "#{config[:provider_uri]}/.well-known/openid-configuration"
    @jwks_url = config[:jwks_url]
    @userinfo_url = config[:userinfo_url]
    @client_id = config[:client_id]
    @client_secret = config[:client_secret]
    @redirect_uri = config[:redirect_uri]
    @provider_uri = config[:provider_uri]
    @issuer = config[:issuer]
    authorization_url = config[:authorization_url] || config[:authorization_endpoint]
    token_url = config[:token_url] || config[:token_endpoint]
    scopes = config[:scopes] || config[:scope]
    use_pkce = config.key?(:use_pkce) ? config[:use_pkce] : true
    additional_params = config[:additional_params]
    revocation_url = config[:revocation_url]

    # Move any remaining options to kwargs
    extra_opts = config.reject { |k, _|
      %i[discovery_url jwks_url userinfo_url client_id
         client_secret redirect_uri provider_uri issuer authorization_url
         authorization_endpoint token_url token_endpoint scopes scope
         use_pkce additional_params revocation_url].include?(k)
    }
    kwargs = kwargs.merge(extra_opts)
  else
    # Store OpenID Connect specific properties from parameters
    @discovery_url = discovery_url
    @jwks_url = jwks_url
    @userinfo_url = userinfo_url
    @client_id = client_id
    @client_secret = client_secret
    @redirect_uri = redirect_uri
    @provider_uri = kwargs[:provider_uri]
    @issuer = kwargs[:issuer]
  end

  # If discovery URL is provided, try to fetch endpoints
  if @discovery_url && (authorization_url.nil? || token_url.nil? || @userinfo_url.nil?)
    endpoints = discover_endpoints
    authorization_url ||= endpoints[:authorization_endpoint]
    token_url ||= endpoints[:token_endpoint]
    @jwks_url ||= endpoints[:jwks_uri]
    @userinfo_url ||= endpoints[:userinfo_endpoint]
    @issuer ||= endpoints[:issuer]
  end

  # Parse and add the openid scope if not present
  oidc_scopes = parse_scopes(scopes)
  oidc_scopes << 'openid' unless oidc_scopes.include?('openid')

  # Call the parent constructor with merged settings
  super(
    authorization_url: authorization_url,
    token_url: token_url,
    scopes: oidc_scopes,
    use_pkce: use_pkce,
    additional_params: additional_params,
    revocation_url: revocation_url,
    client_id: @client_id,
    client_secret: @client_secret,
    redirect_uri: @redirect_uri,
    **kwargs
  )

  # Make sure client_id is properly set after parent initialization
  @client_id = kwargs[:client_id] if @client_id.nil?

  # Validate required fields if this is a direct instance (not a subclass)
  validate! if self.class == Legate::Auth::Schemes::OpenIDConnect
end

Instance Attribute Details

#authorization_urlObject (readonly)

Override to prevent the base URL from being modified with default query parameters Just return the base authorization_url without query parameters



152
153
154
# File 'lib/legate/auth/schemes/openid_connect.rb', line 152

def authorization_url
  @authorization_url
end

#client_idString (readonly)

Returns The client ID.

Returns:

  • (String)

    The client ID



33
34
35
# File 'lib/legate/auth/schemes/openid_connect.rb', line 33

def client_id
  @client_id
end

#discovery_urlString? (readonly)

Returns The URL for the OpenID Connect discovery document.

Returns:

  • (String, nil)

    The URL for the OpenID Connect discovery document



18
19
20
# File 'lib/legate/auth/schemes/openid_connect.rb', line 18

def discovery_url
  @discovery_url
end

#issuerString? (readonly)

Returns The issuer identifier.

Returns:

  • (String, nil)

    The issuer identifier



27
28
29
# File 'lib/legate/auth/schemes/openid_connect.rb', line 27

def issuer
  @issuer
end

#jwks_urlString? (readonly)

Returns The URL for the JWK Set.

Returns:

  • (String, nil)

    The URL for the JWK Set



21
22
23
# File 'lib/legate/auth/schemes/openid_connect.rb', line 21

def jwks_url
  @jwks_url
end

#provider_uriString? (readonly)

Returns The provider URI.

Returns:

  • (String, nil)

    The provider URI



30
31
32
# File 'lib/legate/auth/schemes/openid_connect.rb', line 30

def provider_uri
  @provider_uri
end

#userinfo_urlString? (readonly)

Returns The userinfo endpoint URL.

Returns:

  • (String, nil)

    The userinfo endpoint URL



24
25
26
# File 'lib/legate/auth/schemes/openid_connect.rb', line 24

def userinfo_url
  @userinfo_url
end

Instance Method Details

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

Build the authorization URI for the OpenID Connect 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



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

def build_authorization_uri(config, redirect_uri = nil, state = nil)
  # Generate nonce for OpenID Connect
  nonce = config.options[:nonce] || SecureRandom.hex(16)

  # Store nonce in config for later verification
  config.options[:nonce] = nonce

  # Add nonce to parameters
  additional_params = @additional_params ? @additional_params.dup : {}
  additional_params['nonce'] = nonce

  # Ensure 'openid' scope is included
  oidc_scopes = @scopes.dup
  oidc_scopes << 'openid' unless oidc_scopes.include?('openid')

  # Temporarily store modified scopes
  original_scopes = @scopes
  @scopes = oidc_scopes

  # Temporarily modify additional_params
  original_additional_params = @additional_params
  @additional_params = additional_params

  # Call the parent method
  result = super(config, redirect_uri, state)

  # Restore original additional_params and scopes
  @additional_params = original_additional_params
  @scopes = original_scopes

  result
end

#discover_endpointsHash

Discover OpenID Connect endpoints from the discovery URL

Returns:

  • (Hash)

    The discovered endpoints



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

def discover_endpoints
  return {} unless @discovery_url

  # Skip discovery in test environment to avoid HTTP calls
  return {} if ENV['RSPEC_ENV'] == 'test'

  begin
    validate_auth_url!(@discovery_url, label: 'Discovery URL')
    uri = URI(@discovery_url)
    response = Net::HTTP.get_response(uri)

    unless response.is_a?(Net::HTTPSuccess)
      Legate.logger.error("Failed to fetch OpenID Connect discovery document: #{response.code} #{response.message}")
      return {}
    end

    discovery_data = JSON.parse(response.body)

    {
      authorization_endpoint: discovery_data['authorization_endpoint'],
      token_endpoint: discovery_data['token_endpoint'],
      jwks_uri: discovery_data['jwks_uri'],
      userinfo_endpoint: discovery_data['userinfo_endpoint'],
      issuer: discovery_data['issuer']
    }
  rescue StandardError => e
    Legate.logger.error("Error discovering OpenID Connect endpoints: #{e.message}")
    {}
  end
end

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

Override exchange_token to set correct auth_type

Parameters:

Returns:



196
197
198
199
200
201
202
203
# File 'lib/legate/auth/schemes/openid_connect.rb', line 196

def exchange_token(config, credential)
  result = super(config, credential)

  # Modify the auth_type to be :openid_connect if successful
  result.instance_variable_set(:@auth_type, :openid_connect) if result && result.is_a?(Legate::Auth::ExchangedCredential)

  result
end

#get_userinfo(access_token) ⇒ Hash

Retrieve user information using the access token

Parameters:

  • access_token (String)

    The access token

Returns:

  • (Hash)

    The user information

Raises:

  • (Legate::Auth::Errors::AuthenticationError)

    If user info could not be retrieved



252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
# File 'lib/legate/auth/schemes/openid_connect.rb', line 252

def get_userinfo(access_token)
  # Use the configured userinfo endpoint, fall back to issuer-based URL
  endpoint = @userinfo_url
  endpoint = "#{@issuer}/userinfo" if endpoint.nil? && @issuer

  raise Legate::Auth::Errors::AuthenticationError, 'Userinfo endpoint not configured' unless endpoint

  begin
    validate_auth_url!(endpoint, label: 'Userinfo URL')
    response = Faraday.get(endpoint) do |req|
      req.headers['Authorization'] = "Bearer #{access_token}"
    end

    raise Legate::Auth::Errors::AuthenticationError, "Failed to fetch userinfo: #{response.status} #{response.reason_phrase}" unless response.status == 200

    JSON.parse(response.body)
  rescue Faraday::Error => e
    raise Legate::Auth::Errors::AuthenticationError, "Error fetching userinfo: #{e.message}"
  rescue JSON::ParserError => e
    raise Legate::Auth::Errors::AuthenticationError, "Invalid userinfo response: #{e.message}"
  rescue StandardError => e
    raise Legate::Auth::Errors::AuthenticationError, "Unexpected error fetching userinfo: #{e.message}"
  end
end

#scheme_typeSymbol

Returns The scheme type.

Returns:

  • (Symbol)

    The scheme type



130
131
132
# File 'lib/legate/auth/schemes/openid_connect.rb', line 130

def scheme_type
  :openid_connect
end

#to_hHash

Convert to a hash representation

Returns:

  • (Hash)

    The hash representation of the scheme



207
208
209
210
211
212
213
# File 'lib/legate/auth/schemes/openid_connect.rb', line 207

def to_h
  hash = super
  hash[:discovery_url] = @discovery_url if @discovery_url
  hash[:jwks_url] = @jwks_url if @jwks_url
  hash[:userinfo_url] = @userinfo_url if @userinfo_url
  hash
end

#validate!Object

Validates the scheme configuration

Raises:



136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/legate/auth/schemes/openid_connect.rb', line 136

def validate!
  # Only skip validation in test environment if FORCE_VALIDATE is not true
  in_test = ENV['RSPEC_ENV'] == 'test'
  force_validate = ENV['FORCE_VALIDATE'] == '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

#verify_id_token(id_token, nonce = nil, audience = nil) ⇒ Hash

Verify an ID token using the provider’s JWKS for signature verification.

Parameters:

  • id_token (String)

    The ID token to verify

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

    The nonce to validate against

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

    The expected audience

Returns:

  • (Hash)

    The verified ID token claims

Raises:

  • (Legate::Auth::TokenVerificationError)

    If token verification fails



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

def verify_id_token(id_token, nonce = nil, audience = nil)
  jwks = fetch_jwks
  algorithms = %w[RS256 RS384 RS512 ES256 ES384 ES512]

  decode_opts = { algorithms: algorithms }
  decode_opts[:iss] = @issuer if @issuer
  decode_opts[:verify_iss] = true if @issuer
  decode_opts[:aud] = audience if audience
  decode_opts[:verify_aud] = true if audience

  if jwks && !jwks.empty?
    jwk_set = JWT::JWK::Set.new(jwks)
    payload, _header = JWT.decode(id_token, nil, true, decode_opts) do |header|
      jwk_set.find { |key| key[:kid] == header['kid'] }&.public_key
    end
  else
    # No JWKS available — decode without signature verification
    # but still validate claims. Log a warning since this weakens security.
    Legate.logger.warn('OpenIDConnect: No JWKS available for signature verification — decoding without signature check')
    payload, _header = JWT.decode(id_token, nil, false, algorithms: algorithms)
  end

  raise Legate::Auth::TokenVerificationError, 'ID token nonce mismatch' if nonce && payload['nonce'] != nonce

  payload
rescue JWT::DecodeError => e
  raise Legate::Auth::TokenVerificationError, "Failed to verify ID token: #{e.message}"
rescue StandardError => e
  raise Legate::Auth::TokenVerificationError, "ID token verification failed: #{e.message}"
end