omniauth_oidc 0.2.7 → 1.0.1

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.
@@ -3,7 +3,26 @@
3
3
  module OmniauthOidc
4
4
  class Error < RuntimeError; end
5
5
 
6
+ # Authentication flow errors
6
7
  class MissingCodeError < Error; end
7
-
8
8
  class MissingIdTokenError < Error; end
9
+
10
+ # Configuration errors
11
+ class ConfigurationError < Error; end
12
+ class MissingConfigurationError < ConfigurationError; end
13
+
14
+ # Token/JWT errors
15
+ class TokenError < Error; end
16
+ class TokenVerificationError < TokenError; end
17
+ class TokenExpiredError < TokenError; end
18
+ class InvalidAlgorithmError < TokenError; end
19
+ class InvalidSignatureError < TokenError; end
20
+ class InvalidIssuerError < TokenError; end
21
+ class InvalidAudienceError < TokenError; end
22
+ class InvalidNonceError < TokenError; end
23
+
24
+ # JWKS errors
25
+ class JwksError < Error; end
26
+ class JwksFetchError < JwksError; end
27
+ class KeyNotFoundError < JwksError; end
9
28
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+
7
+ module OmniauthOidc
8
+ # Simple HTTP client using Net::HTTP
9
+ class HttpClient
10
+ class HttpError < StandardError; end
11
+
12
+ MAX_REDIRECTS = 5
13
+
14
+ def self.get(url, headers: {})
15
+ uri = URI.parse(url)
16
+ request = Net::HTTP::Get.new(uri)
17
+ headers.each { |key, value| request[key] = value }
18
+
19
+ response = execute_request(uri, request)
20
+ handle_response(response, url, headers: headers, redirects_remaining: MAX_REDIRECTS)
21
+ end
22
+
23
+ def self.post(url, body: nil, headers: {})
24
+ uri = URI.parse(url)
25
+ request = Net::HTTP::Post.new(uri)
26
+ request["Content-Type"] = "application/x-www-form-urlencoded" unless headers["Content-Type"]
27
+ headers.each { |key, value| request[key] = value }
28
+ request.body = body if body
29
+
30
+ response = execute_request(uri, request)
31
+ handle_response(response, url)
32
+ end
33
+
34
+ def self.execute_request(uri, request)
35
+ OmniauthOidc::Logging.instrument("http.request", method: request.method, uri: uri.to_s) do
36
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") do |http|
37
+ http.request(request)
38
+ end
39
+ end
40
+ end
41
+ private_class_method :execute_request
42
+
43
+ def self.handle_response(response, url, headers: {}, redirects_remaining: 0)
44
+ case response
45
+ when Net::HTTPSuccess
46
+ parse_json_response(response.body)
47
+ when Net::HTTPRedirection
48
+ raise HttpError, "Too many redirects for #{url}" if redirects_remaining <= 0
49
+
50
+ location = response["location"]
51
+ raise HttpError, "HTTP redirect from #{url} with no location header" unless location
52
+
53
+ redirect_uri = URI.parse(location)
54
+ # Resolve relative redirects
55
+ redirect_uri = URI.join(url, location) unless redirect_uri.host
56
+
57
+ OmniauthOidc::Logging.debug("Following redirect", from: url, to: redirect_uri.to_s)
58
+ follow_redirect(redirect_uri, headers: headers, redirects_remaining: redirects_remaining - 1)
59
+ else
60
+ raise HttpError, "HTTP request failed: #{response.code} #{response.message} for #{url}"
61
+ end
62
+ end
63
+ private_class_method :handle_response
64
+
65
+ def self.follow_redirect(uri, headers: {}, redirects_remaining: 0)
66
+ request = Net::HTTP::Get.new(uri)
67
+ headers.each { |key, value| request[key] = value }
68
+
69
+ response = execute_request(uri, request)
70
+ handle_response(response, uri.to_s, headers: headers, redirects_remaining: redirects_remaining)
71
+ end
72
+ private_class_method :follow_redirect
73
+
74
+ def self.parse_json_response(body)
75
+ JSON.parse(body)
76
+ rescue JSON::ParserError => e
77
+ raise HttpError, "Failed to parse JSON response: #{e.message}"
78
+ end
79
+ private_class_method :parse_json_response
80
+ end
81
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jwt"
4
+ require "openssl"
5
+
6
+ module OmniauthOidc
7
+ # Handles JWK/JWKS operations for JWT verification
8
+ class JwkHandler
9
+ # Parsed key with its kid for matching
10
+ KeyWithId = Struct.new(:kid, :keypair, keyword_init: true)
11
+
12
+ def self.parse_jwks(jwks_data)
13
+ return nil unless jwks_data
14
+
15
+ jwks_data = JSON.parse(jwks_data) if jwks_data.is_a?(String)
16
+
17
+ # Handle JWKS (set of keys)
18
+ if jwks_data["keys"]
19
+ jwks_data["keys"].filter_map { |key_data| jwk_to_key(key_data) }
20
+ # Handle single JWK
21
+ else
22
+ [jwk_to_key(jwks_data)].compact
23
+ end
24
+ end
25
+
26
+ def self.jwk_to_key(jwk_data)
27
+ keypair = JWT::JWK.import(jwk_data).keypair
28
+ kid = jwk_data["kid"] || jwk_data[:kid]
29
+ KeyWithId.new(kid: kid, keypair: keypair)
30
+ rescue StandardError => e
31
+ OmniauthOidc::Logging.error("Failed to import JWK", error: e.message)
32
+ nil
33
+ end
34
+
35
+ # Find the right key from JWKS based on kid (key ID)
36
+ def self.find_key(keys, kid = nil)
37
+ return keys.first&.keypair if kid.nil? || keys.size <= 1
38
+
39
+ matched = keys.find { |k| k.kid == kid }
40
+ if matched
41
+ matched.keypair
42
+ else
43
+ OmniauthOidc::Logging.warn("No JWK found matching kid '#{kid}', falling back to first key")
44
+ keys.first&.keypair
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniauthOidc
4
+ # Thread-safe JWKS cache with configurable TTL and force refresh capability
5
+ class JwksCache
6
+ DEFAULT_TTL = 3600 # 1 hour in seconds
7
+
8
+ CacheEntry = Struct.new(:keys, :fetched_at, keyword_init: true)
9
+
10
+ class << self
11
+ def instance
12
+ @instance ||= new
13
+ end
14
+
15
+ # Clear all cached keys (useful for testing)
16
+ def clear!
17
+ instance.clear!
18
+ end
19
+
20
+ # Force refresh a specific JWKS URI on next access
21
+ def invalidate(jwks_uri)
22
+ instance.invalidate(jwks_uri)
23
+ end
24
+ end
25
+
26
+ def initialize(ttl: DEFAULT_TTL)
27
+ @ttl = ttl
28
+ @cache = {}
29
+ @mutex = Mutex.new
30
+ end
31
+
32
+ # Fetch JWKS, using cache if valid
33
+ def fetch(jwks_uri, force_refresh: false, &block)
34
+ @mutex.synchronize do
35
+ entry = @cache[jwks_uri]
36
+
37
+ if !force_refresh && entry && !expired?(entry)
38
+ Logging.debug("JWKS cache hit", jwks_uri: jwks_uri)
39
+ return entry.keys
40
+ end
41
+
42
+ Logging.info("JWKS cache miss, fetching", jwks_uri: jwks_uri, force_refresh: force_refresh)
43
+ keys = block.call
44
+ @cache[jwks_uri] = CacheEntry.new(keys: keys, fetched_at: Time.now)
45
+ keys
46
+ end
47
+ end
48
+
49
+ # Check if a specific URI's cache is valid
50
+ def valid?(jwks_uri)
51
+ @mutex.synchronize do
52
+ entry = @cache[jwks_uri]
53
+ entry && !expired?(entry)
54
+ end
55
+ end
56
+
57
+ # Invalidate a specific URI's cache
58
+ def invalidate(jwks_uri)
59
+ @mutex.synchronize do
60
+ @cache.delete(jwks_uri)
61
+ Logging.debug("JWKS cache invalidated", jwks_uri: jwks_uri)
62
+ end
63
+ end
64
+
65
+ # Clear entire cache
66
+ def clear!
67
+ @mutex.synchronize do
68
+ @cache.clear
69
+ Logging.debug("JWKS cache cleared")
70
+ end
71
+ end
72
+
73
+ # Get current TTL
74
+ attr_reader :ttl
75
+
76
+ # Update TTL (affects future expiration checks)
77
+ def ttl=(value)
78
+ @mutex.synchronize do
79
+ @ttl = value
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def expired?(entry)
86
+ Time.now - entry.fetched_at > @ttl
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module OmniauthOidc
6
+ # Logging module providing both Ruby Logger and ActiveSupport::Notifications support
7
+ module Logging
8
+ class << self
9
+ attr_writer :logger
10
+
11
+ def logger
12
+ @logger ||= default_logger
13
+ end
14
+
15
+ def log_level=(level)
16
+ logger.level = level
17
+ end
18
+
19
+ def instrument(event_name, payload = {}, &block)
20
+ full_event_name = "#{event_name}.omniauth_oidc"
21
+
22
+ # Always log the event
23
+ log_event(event_name, payload)
24
+
25
+ # Use ActiveSupport::Notifications if available
26
+ if defined?(ActiveSupport::Notifications)
27
+ ActiveSupport::Notifications.instrument(full_event_name, payload, &block)
28
+ elsif block
29
+ yield payload
30
+ end
31
+ end
32
+
33
+ def debug(message, context = {})
34
+ log(:debug, message, context)
35
+ end
36
+
37
+ def info(message, context = {})
38
+ log(:info, message, context)
39
+ end
40
+
41
+ def warn(message, context = {})
42
+ log(:warn, message, context)
43
+ end
44
+
45
+ def error(message, context = {})
46
+ log(:error, message, context)
47
+ end
48
+
49
+ private
50
+
51
+ def default_logger
52
+ Logger.new($stdout).tap do |log|
53
+ log.progname = "OmniauthOidc"
54
+ log.level = Logger::WARN # Default to WARN to avoid noise
55
+ end
56
+ end
57
+
58
+ def log(level, message, context)
59
+ formatted_message = context.empty? ? message : "#{message} #{context.inspect}"
60
+ logger.send(level, formatted_message)
61
+ end
62
+
63
+ def log_event(event_name, payload)
64
+ # Log at debug level for instrumentation events
65
+ sanitized_payload = sanitize_payload(payload)
66
+ debug("Event: #{event_name}", sanitized_payload)
67
+ end
68
+
69
+ def sanitize_payload(payload)
70
+ # Remove sensitive data from logs
71
+ sensitive_keys = %i[secret client_secret access_token id_token refresh_token code_verifier]
72
+ payload.reject { |k, _| sensitive_keys.include?(k.to_sym) }
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmniauthOidc
4
+ # Simple response objects to replace OpenIDConnect::ResponseObject classes
5
+ module ResponseObjects
6
+ # Represents an OIDC ID Token with claims
7
+ class IdToken
8
+ attr_reader :raw_attributes
9
+
10
+ def initialize(attributes = {})
11
+ @raw_attributes = attributes.is_a?(Hash) ? attributes : attributes.to_h
12
+ end
13
+
14
+ def sub
15
+ raw_attributes["sub"]
16
+ end
17
+
18
+ def iss
19
+ raw_attributes["iss"]
20
+ end
21
+
22
+ def aud
23
+ raw_attributes["aud"]
24
+ end
25
+
26
+ def exp
27
+ raw_attributes["exp"]
28
+ end
29
+
30
+ def iat
31
+ raw_attributes["iat"]
32
+ end
33
+
34
+ def nonce
35
+ raw_attributes["nonce"]
36
+ end
37
+
38
+ # Allow method-style access to claims
39
+ def method_missing(method_name, *args)
40
+ return raw_attributes[method_name.to_s] if raw_attributes.key?(method_name.to_s)
41
+
42
+ super
43
+ end
44
+
45
+ def respond_to_missing?(method_name, include_private = false)
46
+ raw_attributes.key?(method_name.to_s) || super
47
+ end
48
+ end
49
+
50
+ # Represents OIDC UserInfo response
51
+ class UserInfo
52
+ attr_reader :raw_attributes
53
+
54
+ def initialize(attributes = {})
55
+ @raw_attributes = attributes.is_a?(Hash) ? attributes : attributes.to_h
56
+ end
57
+
58
+ # Standard OIDC claims
59
+ def sub
60
+ raw_attributes["sub"]
61
+ end
62
+
63
+ def name
64
+ raw_attributes["name"]
65
+ end
66
+
67
+ def given_name
68
+ raw_attributes["given_name"]
69
+ end
70
+
71
+ def family_name
72
+ raw_attributes["family_name"]
73
+ end
74
+
75
+ def middle_name
76
+ raw_attributes["middle_name"]
77
+ end
78
+
79
+ def nickname
80
+ raw_attributes["nickname"]
81
+ end
82
+
83
+ def preferred_username
84
+ raw_attributes["preferred_username"]
85
+ end
86
+
87
+ def profile
88
+ raw_attributes["profile"]
89
+ end
90
+
91
+ def picture
92
+ raw_attributes["picture"]
93
+ end
94
+
95
+ def website
96
+ raw_attributes["website"]
97
+ end
98
+
99
+ def email
100
+ raw_attributes["email"]
101
+ end
102
+
103
+ def email_verified
104
+ raw_attributes["email_verified"]
105
+ end
106
+
107
+ def gender
108
+ raw_attributes["gender"]
109
+ end
110
+
111
+ def birthdate
112
+ raw_attributes["birthdate"]
113
+ end
114
+
115
+ def zoneinfo
116
+ raw_attributes["zoneinfo"]
117
+ end
118
+
119
+ def locale
120
+ raw_attributes["locale"]
121
+ end
122
+
123
+ def phone_number
124
+ raw_attributes["phone_number"]
125
+ end
126
+
127
+ def phone_number_verified
128
+ raw_attributes["phone_number_verified"]
129
+ end
130
+
131
+ def address
132
+ raw_attributes["address"]
133
+ end
134
+
135
+ def updated_at
136
+ raw_attributes["updated_at"]
137
+ end
138
+
139
+ # Allow method-style access to custom claims
140
+ def method_missing(method_name, *args)
141
+ return raw_attributes[method_name.to_s] if raw_attributes.key?(method_name.to_s)
142
+
143
+ super
144
+ end
145
+
146
+ def respond_to_missing?(method_name, include_private = false)
147
+ raw_attributes.key?(method_name.to_s) || super
148
+ end
149
+ end
150
+
151
+ # Represents an OAuth2 Access Token
152
+ class AccessToken
153
+ attr_reader :access_token, :token_type, :expires_in, :refresh_token, :scope, :id_token
154
+
155
+ def initialize(attributes = {})
156
+ @access_token = attributes["access_token"] || attributes[:access_token]
157
+ @token_type = attributes["token_type"] || attributes[:token_type]
158
+ @expires_in = attributes["expires_in"] || attributes[:expires_in]
159
+ @refresh_token = attributes["refresh_token"] || attributes[:refresh_token]
160
+ @scope = attributes["scope"] || attributes[:scope]
161
+ @id_token = attributes["id_token"] || attributes[:id_token]
162
+ end
163
+
164
+ def to_h
165
+ {
166
+ "access_token" => access_token,
167
+ "token_type" => token_type,
168
+ "expires_in" => expires_in,
169
+ "refresh_token" => refresh_token,
170
+ "scope" => scope,
171
+ "id_token" => id_token
172
+ }.compact
173
+ end
174
+ end
175
+ end
176
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OmniauthOidc
4
- VERSION = "0.2.7"
4
+ VERSION = "1.0.1"
5
5
  end