omniauth-oidc-strategy 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAuth
4
+ module Strategies
5
+ class Oidc
6
+ module Serializer
7
+ def serialized_access_token_auth_hash
8
+ {
9
+ provider: name,
10
+ credentials: serialized_credentials
11
+ }
12
+ end
13
+
14
+ def serialized_credentials
15
+ {
16
+ id_token: access_token.id_token,
17
+ token: access_token.access_token,
18
+ refresh_token: access_token.refresh_token,
19
+ expires_in: access_token.expires_in,
20
+ scope: access_token.scope
21
+ }
22
+ end
23
+
24
+ def serialized_extra
25
+ {
26
+ claims: id_token_raw_attributes,
27
+ scope: scope
28
+ }
29
+ end
30
+
31
+ def serialized_request_options
32
+ {
33
+ response_type: options.response_type,
34
+ response_mode: options.response_mode,
35
+ scope: scope,
36
+ state: new_state,
37
+ login_hint: params["login_hint"],
38
+ ui_locales: params["ui_locales"],
39
+ claims_locales: params["claims_locales"],
40
+ prompt: options.prompt,
41
+ nonce: (new_nonce if options.send_nonce),
42
+ hd: options.hd,
43
+ acr_values: options.acr_values
44
+ }
45
+ end
46
+
47
+ def serialized_user_info
48
+ {
49
+ name: user_info.name,
50
+ email: user_info.email,
51
+ email_verified: user_info.email_verified,
52
+ first_name: user_info.given_name,
53
+ last_name: user_info.family_name,
54
+ phone: user_info.phone_number,
55
+ address: user_info.address
56
+ }
57
+ end
58
+
59
+ def serialized_user_info_auth_hash
60
+ {
61
+ provider: name,
62
+ uid: user_info.sub,
63
+ info: serialized_user_info,
64
+ extra: serialized_extra,
65
+ credentials: serialized_credentials
66
+ }
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "faraday/net_http_persistent"
5
+ require "faraday/retry"
6
+
7
+ module OmniAuth
8
+ module Strategies
9
+ class Oidc
10
+ # HTTP transport layer using Faraday
11
+ module Transport
12
+ module_function
13
+
14
+ def connection
15
+ @connection ||= Faraday.new do |f|
16
+ f.request :retry, max: 2, interval: 0.5, backoff_factor: 2
17
+ f.headers["User-Agent"] = OmniauthOidc::USER_AGENT
18
+ f.ssl.min_version = OpenSSL::SSL::TLS1_2_VERSION
19
+ f.adapter :net_http_persistent
20
+ end
21
+ end
22
+
23
+ def get(url, headers: {})
24
+ connection.get(url) do |req|
25
+ req.headers.merge!(headers)
26
+ end
27
+ end
28
+
29
+ def post(url, headers: {}, body: nil)
30
+ connection.post(url) do |req|
31
+ req.headers.merge!(headers)
32
+ req.body = body
33
+ end
34
+ end
35
+
36
+ def fetch_json(url, headers: {})
37
+ response = get(url, headers: headers)
38
+ JSON.parse(response.body)
39
+ end
40
+
41
+ def reset!
42
+ @connection = nil
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniAuth
4
+ module Strategies
5
+ class Oidc
6
+ # Token verification phase
7
+ module Verify # rubocop:disable Metrics/ModuleLength
8
+ def secret
9
+ base64_decoded_jwt_secret || client_options.secret
10
+ end
11
+
12
+ # https://tools.ietf.org/html/rfc7636#appendix-A
13
+ def pkce_authorize_params(verifier)
14
+ {
15
+ code_challenge: options.pkce_options[:code_challenge].call(verifier),
16
+ code_challenge_method: options.pkce_options[:code_challenge_method]
17
+ }
18
+ end
19
+
20
+ # Looks for key defined in omniauth initializer, if none is defined
21
+ # falls back to using jwks_uri returned by OIDC config_endpoint
22
+ def public_key
23
+ @public_key ||= if configured_public_key
24
+ configured_public_key
25
+ elsif config.jwks_uri
26
+ fetch_key
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :decoded_id_token
33
+
34
+ def fetch_key
35
+ parse_jwk_key(jwks_key)
36
+ end
37
+
38
+ def jwks_key
39
+ @jwks_key ||= Transport.fetch_json(config.jwks_uri)
40
+ end
41
+
42
+ def base64_decoded_jwt_secret
43
+ return unless options.jwt_secret_base64
44
+
45
+ Base64.decode64(options.jwt_secret_base64)
46
+ end
47
+
48
+ def verify_id_token!(id_token)
49
+ return unless id_token
50
+ decode_id_token(id_token).verify!(issuer: config.issuer,
51
+ client_id: client_options.identifier,
52
+ nonce: stored_nonce)
53
+ end
54
+
55
+ def decode_id_token(id_token)
56
+ decoded = JSON::JWT.decode(id_token, :skip_verification)
57
+ algorithm = decoded.algorithm.to_sym
58
+
59
+ validate_client_algorithm!(algorithm)
60
+
61
+ keyset =
62
+ case algorithm
63
+ when :HS256, :HS384, :HS512
64
+ secret
65
+ else
66
+ public_key
67
+ end
68
+
69
+ decoded.verify!(keyset)
70
+ @decoded_id_token = ::OpenIDConnect::ResponseObject::IdToken.new(decoded)
71
+ rescue JSON::JWK::Set::KidNotFound
72
+ # Workaround for https://github.com/nov/json-jwt/pull/92#issuecomment-824654949
73
+ raise if decoded&.header&.key?("kid")
74
+
75
+ decoded = decode_with_each_key!(id_token, keyset)
76
+
77
+ raise unless decoded
78
+
79
+ @decoded_id_token = decoded
80
+ end
81
+
82
+ # Check for jwt to match defined client_signing_alg
83
+ def validate_client_algorithm!(algorithm)
84
+ client_signing_alg = options.client_signing_alg&.to_sym
85
+
86
+ return unless client_signing_alg
87
+ return if algorithm == client_signing_alg
88
+
89
+ reason = "Received JWT is signed with #{algorithm}, but client_signing_alg is \
90
+ configured for #{client_signing_alg}"
91
+ raise CallbackError, error: :invalid_jwt_algorithm, reason: reason, uri: params["error_uri"]
92
+ end
93
+
94
+ def decode!(id_token, key)
95
+ ::OpenIDConnect::ResponseObject::IdToken.decode(id_token, key)
96
+ end
97
+
98
+ def decode_with_each_key!(id_token, keyset)
99
+ return unless keyset.is_a?(JSON::JWK::Set)
100
+
101
+ keyset.each do |key|
102
+ begin
103
+ decoded = decode!(id_token, key)
104
+ rescue JSON::JWS::VerificationFailed, JSON::JWS::UnexpectedAlgorithm, JSON::JWK::UnknownAlgorithm
105
+ next
106
+ end
107
+
108
+ return decoded if decoded
109
+ end
110
+
111
+ nil
112
+ end
113
+
114
+ def stored_nonce
115
+ session.delete("omniauth.nonce")
116
+ end
117
+
118
+ def configured_public_key
119
+ @configured_public_key ||= if options.client_jwk_signing_key
120
+ parse_jwk_key(options.client_jwk_signing_key)
121
+ elsif options.client_x509_signing_key
122
+ parse_x509_key(options.client_x509_signing_key)
123
+ end
124
+ end
125
+
126
+ def parse_x509_key(key)
127
+ OpenSSL::X509::Certificate.new(key).public_key
128
+ end
129
+
130
+ def parse_jwk_key(key)
131
+ json = key.is_a?(String) ? JSON.parse(key) : key
132
+ return JSON::JWK::Set.new(json["keys"]) if json.key?("keys")
133
+
134
+ JSON::JWK.new(json)
135
+ end
136
+
137
+ def decode(str)
138
+ UrlSafeBase64.decode64(str).unpack1("B*").to_i(2).to_s
139
+ end
140
+
141
+ # Converts camelCase keys to snake_case symbols. Handles standard OIDC
142
+ # claim names (e.g. "givenName" → :given_name). Does not handle acronym
143
+ # runs like "HTTPSEnabled" — not expected in OIDC responses.
144
+ def deep_underscore_keys(hash)
145
+ hash.each_with_object({}) do |(key, value), result|
146
+ new_key = key.to_s.gsub(/([A-Z])/, '_\1').sub(/^_/, "").downcase.to_sym
147
+ result[new_key] = value.is_a?(Hash) ? deep_underscore_keys(value) : value
148
+ end
149
+ end
150
+
151
+ def id_token_raw_attributes
152
+ decoded_id_token.raw_attributes
153
+ end
154
+
155
+ def user_info
156
+ return @user_info if @user_info
157
+
158
+ if id_token_raw_attributes
159
+ merged_user_info = access_token.userinfo!.raw_attributes.merge(id_token_raw_attributes)
160
+
161
+ @user_info = ::OpenIDConnect::ResponseObject::UserInfo.new(
162
+ deep_underscore_keys(merged_user_info)
163
+ )
164
+ else
165
+ @user_info = access_token.userinfo!
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "timeout"
5
+ require "oauth2"
6
+ require "omniauth"
7
+ require "openid_connect"
8
+ require "forwardable"
9
+
10
+ require_relative "oidc/callback"
11
+ require_relative "oidc/request"
12
+ require_relative "oidc/serializer"
13
+ require_relative "oidc/transport"
14
+ require_relative "oidc/verify"
15
+
16
+ module OmniAuth
17
+ module Strategies
18
+ # OIDC strategy for omniauth
19
+ class Oidc
20
+ include OmniAuth::Strategy
21
+ include Callback
22
+ include Request
23
+ include Serializer
24
+ include Verify
25
+
26
+ extend Forwardable
27
+
28
+ RESPONSE_TYPE_EXCEPTIONS = {
29
+ "id_token" => { exception_class: OmniauthOidc::MissingIdTokenError, key: :missing_id_token }.freeze,
30
+ "code" => { exception_class: OmniauthOidc::MissingCodeError, key: :missing_code }.freeze
31
+ }.freeze
32
+
33
+ def_delegator :request, :params
34
+
35
+ option :name, :oidc # to separate each oidc provider available in the app
36
+ option(:client_options, identifier: nil, # client id, required
37
+ secret: nil, # client secret, required
38
+ host: nil, # oidc provider host, optional
39
+ scheme: "https", # connection scheme, optional
40
+ port: 443, # connection port, optional
41
+ config_endpoint: nil, # all data will be fetched from here, required
42
+ authorization_endpoint: nil, # optional
43
+ token_endpoint: nil, # optional
44
+ userinfo_endpoint: nil, # optional
45
+ jwks_uri: nil, # optional
46
+ end_session_endpoint: nil, # optional
47
+ environment: nil) # optional
48
+
49
+ option :issuer
50
+ option :client_signing_alg
51
+ option :jwt_secret_base64
52
+ option :client_jwk_signing_key
53
+ option :client_x509_signing_key
54
+ option :scope, [ :openid ]
55
+ option :response_type, "code" # ['code', 'id_token']
56
+ option :require_state, true
57
+ option :state
58
+ option :response_mode # [:query, :fragment, :form_post, :web_message]
59
+ option :display, nil # [:page, :popup, :touch, :wap]
60
+ option :prompt, nil # [:none, :login, :consent, :select_account]
61
+ option :hd, nil
62
+ option :max_age
63
+ option :ui_locales
64
+ option :id_token_hint
65
+ option :acr_values
66
+ option :send_nonce, true
67
+ option :fetch_user_info, true
68
+ option :send_scope_to_token_endpoint, true
69
+ option :client_auth_method
70
+ option :post_logout_redirect_uri
71
+ option :extra_authorize_params, {}
72
+ option :allow_authorize_params, []
73
+ option :uid_field, "sub"
74
+ option :pkce, false
75
+ option :pkce_verifier, nil
76
+ option :pkce_options, {
77
+ code_challenge: proc { |verifier|
78
+ Base64.urlsafe_encode64(Digest::SHA2.digest(verifier), padding: false)
79
+ },
80
+ code_challenge_method: "S256"
81
+ }
82
+
83
+ option :logout_path, "/logout"
84
+
85
+ # Cross-module state contract. These methods and instance variables are
86
+ # shared between Callback, Verify, and Serializer modules during the
87
+ # callback phase:
88
+ #
89
+ # Callback provides:
90
+ # access_token — OpenIDConnect access token (lazy-initialized, memoized)
91
+ # store_id_token — persists id_token to session for RP-Initiated Logout
92
+ #
93
+ # Verify provides:
94
+ # user_info — merged UserInfo from access token + id_token claims
95
+ # decoded_id_token — decoded and verified JWT (attr_reader)
96
+ # verify_id_token! — verifies id_token signature, issuer, nonce
97
+ # decode_id_token — decodes JWT, sets @decoded_id_token
98
+ # secret, public_key — signing key resolution
99
+ #
100
+ # Serializer reads:
101
+ # access_token, user_info, decoded_id_token (via id_token_raw_attributes)
102
+ #
103
+ # Oidc (this class) provides to all modules:
104
+ # client, config, client_options, scope, session, params, options,
105
+ # redirect_uri, stored_state, new_nonce, host, issuer
106
+
107
+ SECURITY_HEADERS = {
108
+ "Cache-Control" => "no-cache, no-store, must-revalidate",
109
+ "Pragma" => "no-cache",
110
+ "Referrer-Policy" => "no-referrer"
111
+ }.freeze
112
+
113
+ def redirect(uri)
114
+ response = super
115
+ SECURITY_HEADERS.each { |k, v| response[1][k] = v }
116
+ response
117
+ end
118
+
119
+ def uid
120
+ user_info.raw_attributes[options.uid_field.to_sym] || user_info.sub
121
+ end
122
+
123
+ info { serialized_user_info }
124
+
125
+ extra { serialized_extra }
126
+
127
+ credentials { serialized_credentials }
128
+
129
+ # Initialize OpenIDConnect Client with options
130
+ def client
131
+ @client ||= ::OpenIDConnect::Client.new(client_options)
132
+ end
133
+
134
+ # Returns an OAuth2::Client (from the oauth2 gem) configured with the
135
+ # discovered token endpoint. Useful for token refresh flows where
136
+ # OAuth2::AccessToken requires an OAuth2::Client rather than the
137
+ # OpenIDConnect/Rack::OAuth2 client returned by #client.
138
+ def oauth2_client
139
+ @oauth2_client ||= ::OAuth2::Client.new(
140
+ client_options.identifier,
141
+ client_options.secret,
142
+ site: config.issuer,
143
+ token_url: config.token_endpoint
144
+ )
145
+ end
146
+
147
+ # Config is built from the JSON response from the OIDC config endpoint
148
+ def config
149
+ unless client_options.config_endpoint || params["config_endpoint"]
150
+ raise Error,
151
+ "Configuration endpoint is missing from options"
152
+ end
153
+
154
+ @config ||= OmniauthOidc::Config.fetch(client_options.config_endpoint)
155
+ end
156
+
157
+ # Detects if current request is for the logout url and makes a redirect to end session with OIDC provider
158
+ def other_phase
159
+ if logout_path_pattern.match?(request.url)
160
+ options.issuer = issuer if options.issuer.to_s.empty?
161
+
162
+ return redirect(end_session_uri) if end_session_uri
163
+ end
164
+ call_app!
165
+ end
166
+
167
+ # URL to end authenticated user's session with OIDC provider
168
+ def end_session_uri
169
+ return unless end_session_endpoint_is_valid?
170
+
171
+ end_session = URI(client_options.end_session_endpoint)
172
+ end_session_params = {}
173
+ end_session_params[:post_logout_redirect_uri] = options.post_logout_redirect_uri if options.post_logout_redirect_uri
174
+ end_session_params[:id_token_hint] = session["omniauth.id_token"] if session["omniauth.id_token"]
175
+ end_session.query = URI.encode_www_form(end_session_params) unless end_session_params.empty?
176
+ end_session.to_s
177
+ end
178
+
179
+ private
180
+
181
+ def issuer
182
+ @issuer ||= config.issuer
183
+ end
184
+
185
+ def host
186
+ @host ||= URI.parse(config.issuer).host
187
+ end
188
+
189
+ # get scope list from options or provider config defaults
190
+ def scope
191
+ options.scope || config.scopes_supported
192
+ end
193
+
194
+ def authorization_code
195
+ params["code"]
196
+ end
197
+
198
+ def client_options
199
+ options.client_options
200
+ end
201
+
202
+ def stored_state
203
+ session.delete("omniauth.state")
204
+ end
205
+
206
+ def new_nonce
207
+ session["omniauth.nonce"] = SecureRandom.hex(16)
208
+ end
209
+
210
+ def script_name
211
+ return "" if @env.nil?
212
+
213
+ super
214
+ end
215
+
216
+ def session
217
+ return {} if @env.nil?
218
+
219
+ super
220
+ end
221
+
222
+ def redirect_uri
223
+ options.redirect_uri || full_host + callback_path
224
+ end
225
+
226
+ # Configure OIDC discovery endpoints on a target object (client_options or client).
227
+ # Called by both Request and Callback phases to avoid duplication.
228
+ def configure_discovery_endpoints(target)
229
+ target.host = host
230
+ target.authorization_endpoint = config.authorization_endpoint
231
+ target.token_endpoint = config.token_endpoint
232
+ target.userinfo_endpoint = config.userinfo_endpoint
233
+
234
+ if target.respond_to?(:jwks_uri=)
235
+ target.jwks_uri = config.jwks_uri
236
+ end
237
+
238
+ if config.end_session_endpoint && target.respond_to?(:end_session_endpoint=)
239
+ target.end_session_endpoint = config.end_session_endpoint
240
+ end
241
+ end
242
+
243
+ def end_session_endpoint_is_valid?
244
+ client_options.end_session_endpoint &&
245
+ client_options.end_session_endpoint.match?(URI::RFC2396_PARSER.make_regexp)
246
+ end
247
+
248
+ def logout_path_pattern
249
+ @logout_path_pattern ||= /\A#{Regexp.quote(request.base_url)}#{options.logout_path}/
250
+ end
251
+
252
+ # Override for the CallbackError class
253
+ class CallbackError < StandardError
254
+ attr_accessor :error, :error_reason, :error_uri
255
+
256
+ def initialize(data)
257
+ super
258
+ self.error = data[:error]
259
+ self.error_reason = data[:reason]
260
+ self.error_uri = data[:uri]
261
+ end
262
+
263
+ def message
264
+ [ error, error_reason, error_uri ].compact.join(" | ")
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
270
+
271
+ OmniAuth.config.add_camelization "OmniauthOidc", "OmniAuthOidc"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "omniauth/oidc/config"
4
+ require_relative "omniauth/oidc/errors"
5
+ require_relative "omniauth/oidc/user_agent"
6
+ require_relative "omniauth/oidc/version"
7
+ require_relative "omniauth/strategies/oidc"
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/omniauth/oidc/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "omniauth-oidc-strategy"
7
+ spec.version = OmniauthOidc::VERSION
8
+ spec.authors = [ 'mc' ]
9
+ spec.email = [ 'test@example.com' ]
10
+
11
+ spec.summary = "Omniauth strategy to authenticate and retrieve user data using OpenID Connect (OIDC)"
12
+ spec.description = "Omniauth strategy to authenticate and retrieve user data as a client using OpenID Connect (OIDC)
13
+ suited for multiple OIDC providers."
14
+ spec.homepage = "https://github.com/CanalWestStudio/omniauth-oidc"
15
+ spec.license = "MIT"
16
+ spec.required_ruby_version = ">= 3.1.0"
17
+
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = "https://github.com/CanalWestStudio/omniauth-oidc"
20
+ spec.metadata["changelog_uri"] = "https://github.com/CanalWestStudio/omniauth-oidc/blob/main/CHANGELOG.md"
21
+
22
+ # Specify which files should be added to the gem when it is released.
23
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
24
+ spec.files = Dir.chdir(__dir__) do
25
+ `git ls-files -z`.split("\x0").reject do |f|
26
+ (File.expand_path(f) == __FILE__) ||
27
+ f.start_with?(*%w[bin/ test/ spec/ features/ .git .github appveyor Gemfile])
28
+ end
29
+ end
30
+ spec.bindir = "exe"
31
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32
+ spec.require_paths = [ "lib" ]
33
+
34
+ spec.add_dependency "faraday", "~> 2.0"
35
+ spec.add_dependency "faraday-net_http_persistent", "~> 2.0"
36
+ spec.add_dependency "faraday-retry", "~> 2.0"
37
+ spec.add_dependency "oauth2", ">= 1.4"
38
+ spec.add_dependency "omniauth"
39
+ spec.add_dependency "openid_connect"
40
+
41
+ # For more information and examples about making a new gem, check out our
42
+ # guide at: https://bundler.io/guides/creating_gem.html
43
+ end
@@ -0,0 +1,4 @@
1
+ module OmniauthOidc
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end