better_auth-oidc 0.10.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9646ecf4d95be3d28384445b5a3b460fc4ee8c3595664314a7a440d0c26650cc
4
+ data.tar.gz: aea87903329b9d2bfcb3fc8daf06082c09a6285a406ff64ea67b1dfb67487fd3
5
+ SHA512:
6
+ metadata.gz: 58b8f64358ad8904db13f6ab687a24c07dd35a6f76c43c9141803ae7c66916cb1b8ca502fa43c930b93fac289abf6012dad9be07b1a08952956bdded208e2f71
7
+ data.tar.gz: de48d85d3db4ef09297177aed53f604abb4a4a46a0f826e8baf78ea2e993ecee3aba89e696d6dc8597719689d6d1c475112d8ada6892dc85d60486ead915fa8d
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Split OIDC relying-party code out of `better_auth-sso` into a dedicated gem without `ruby-saml`.
6
+
7
+ ## 0.10.0
8
+
9
+ - Initial release (extracted from `better_auth-sso` 0.10.0).
data/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # Better Auth OIDC
2
+
3
+ Enterprise OpenID Connect relying-party helpers for Better Auth Ruby.
4
+
5
+ Use this package when you need OIDC discovery, JWKS validation, and plugin extensions without pulling in SAML/XML dependencies.
6
+
7
+ ```ruby
8
+ require "better_auth"
9
+ require "better_auth/oidc"
10
+ ```
11
+
12
+ For the full SSO plugin (provider CRUD, domain verification, composed routes), add `better_auth-sso`:
13
+
14
+ ```ruby
15
+ gem "better_auth-sso"
16
+ gem "better_auth-saml" # only when using SAML identity providers
17
+ ```
18
+
19
+ ```ruby
20
+ require "better_auth/sso"
21
+
22
+ BetterAuth.auth(plugins: [BetterAuth::Plugins.sso])
23
+ ```
24
+
25
+ SCIM provisioning is separate (`better_auth-scim`). SAML SP primitives live in `better_auth-saml`.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module OIDC
5
+ VERSION = "0.10.0"
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "better_auth"
4
+ require "jwt"
5
+ require_relative "oidc/version"
6
+ require_relative "sso/oidc"
7
+ require_relative "sso/oidc/discovery"
8
+ require_relative "sso/oidc/errors"
9
+ require_relative "sso/oidc/types"
10
+ require_relative "sso/plugin/oidc_core"
11
+ require_relative "plugins/oidc"
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "cgi"
5
+ require "json"
6
+ require "jwt"
7
+ require "net/http"
8
+ require "openssl"
9
+ require "resolv"
10
+ require "securerandom"
11
+ require "time"
12
+ require "uri"
13
+ require "zlib"
14
+
15
+ require_relative "../sso/plugin/oidc_discovery"
16
+ require_relative "../sso/plugin/oidc_runtime"
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "json"
5
+ require "net/http"
6
+
7
+ module BetterAuth
8
+ module SSO
9
+ module OIDC
10
+ module Discovery
11
+ module_function
12
+
13
+ REQUIRED_DISCOVERY_FIELDS = %i[issuer authorization_endpoint token_endpoint jwks_uri].freeze
14
+ DISCOVERY_URL_FIELDS = %i[
15
+ token_endpoint
16
+ authorization_endpoint
17
+ jwks_uri
18
+ userinfo_endpoint
19
+ revocation_endpoint
20
+ end_session_endpoint
21
+ introspection_endpoint
22
+ ].freeze
23
+
24
+ def compute_discovery_url(issuer)
25
+ "#{issuer.to_s.sub(%r{/+\z}, "")}/.well-known/openid-configuration"
26
+ end
27
+
28
+ def validate_discovery_url(url, trusted_origin = nil)
29
+ uri = parse_http_url!(url, "discoveryEndpoint", details: {url: url})
30
+ return true unless trusted_origin && !trusted_origin.call(uri.to_s)
31
+
32
+ raise DiscoveryError.new(
33
+ "discovery_untrusted_origin",
34
+ "The main discovery endpoint \"#{uri}\" is not trusted by your trusted origins configuration.",
35
+ details: {url: uri.to_s}
36
+ )
37
+ end
38
+
39
+ def validate_discovery_document(document, issuer)
40
+ doc = BetterAuth::Plugins.normalize_hash(document || {})
41
+ missing = REQUIRED_DISCOVERY_FIELDS.select { |field| doc[field].to_s.empty? }
42
+ unless missing.empty?
43
+ raise DiscoveryError.new(
44
+ "discovery_incomplete",
45
+ "OIDC discovery document is missing required fields: #{missing.join(", ")}",
46
+ details: {missingFields: missing.map(&:to_s)}
47
+ )
48
+ end
49
+
50
+ discovered = doc[:issuer].to_s.sub(%r{/+\z}, "")
51
+ configured = issuer.to_s.sub(%r{/+\z}, "")
52
+ return true if discovered == configured
53
+
54
+ raise DiscoveryError.new(
55
+ "issuer_mismatch",
56
+ "OIDC discovery issuer does not match configured issuer",
57
+ details: {discovered: doc[:issuer], configured: issuer}
58
+ )
59
+ end
60
+
61
+ def normalize_discovery_urls(document, issuer, trusted_origin = nil)
62
+ doc = BetterAuth::Plugins.normalize_hash(document || {}).dup
63
+ DISCOVERY_URL_FIELDS.each do |field|
64
+ next if doc[field].to_s.empty?
65
+
66
+ doc[field] = normalize_url(field.to_s, doc[field], issuer, trusted_origin)
67
+ end
68
+ doc
69
+ end
70
+
71
+ def fetch_discovery_document(url, timeout: nil, fetch: nil)
72
+ response = if fetch
73
+ fetch.call(url, timeout: timeout)
74
+ else
75
+ uri = URI(url)
76
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: timeout) do |http|
77
+ http.get(uri.request_uri)
78
+ end
79
+ end
80
+ parse_discovery_fetch_response(response)
81
+ rescue DiscoveryError
82
+ raise
83
+ rescue Timeout::Error
84
+ raise DiscoveryError.new("discovery_timeout", "OIDC discovery request timed out", details: {url: url})
85
+ rescue => exception
86
+ if exception.message.match?(/aborted/i)
87
+ raise DiscoveryError.new("discovery_timeout", "OIDC discovery request timed out", details: {url: url})
88
+ end
89
+
90
+ raise DiscoveryError.new("discovery_unexpected_error", "OIDC discovery request failed", details: {url: url, error: exception.message})
91
+ end
92
+
93
+ def discover_oidc_config(issuer:, fetch: nil, existing_config: nil, discovery_endpoint: nil, trusted_origin: nil, is_trusted_origin: nil, timeout: nil)
94
+ existing = BetterAuth::Plugins.normalize_hash(existing_config || {})
95
+ origin_check = trusted_origin || is_trusted_origin
96
+ discovery_url = discovery_endpoint || existing[:discovery_endpoint] || compute_discovery_url(issuer)
97
+ validate_discovery_url(discovery_url, origin_check)
98
+
99
+ document = fetch_discovery_document(discovery_url, timeout: timeout, fetch: fetch)
100
+ validate_discovery_document(document, issuer)
101
+ normalized_document = normalize_discovery_urls(document, issuer, origin_check)
102
+
103
+ {
104
+ issuer: existing[:issuer] || normalized_document[:issuer],
105
+ discovery_endpoint: existing[:discovery_endpoint] || discovery_url,
106
+ client_id: existing[:client_id],
107
+ client_secret: existing[:client_secret],
108
+ authorization_endpoint: existing[:authorization_endpoint] || normalized_document[:authorization_endpoint],
109
+ token_endpoint: existing[:token_endpoint] || normalized_document[:token_endpoint],
110
+ jwks_endpoint: existing[:jwks_endpoint] || normalized_document[:jwks_uri],
111
+ user_info_endpoint: existing[:user_info_endpoint] || normalized_document[:userinfo_endpoint],
112
+ token_endpoint_authentication: select_token_endpoint_auth_method(normalized_document, existing[:token_endpoint_authentication]),
113
+ scopes_supported: existing[:scopes_supported] || normalized_document[:scopes_supported],
114
+ pkce: existing[:pkce],
115
+ override_user_info: existing[:override_user_info],
116
+ mapping: existing[:mapping]
117
+ }.compact
118
+ end
119
+
120
+ def normalize_url(name_or_value, value_or_issuer, issuer = nil, trusted_origin = nil)
121
+ name = issuer.nil? ? "url" : name_or_value.to_s
122
+ value = issuer.nil? ? name_or_value : value_or_issuer
123
+ issuer_value = issuer.nil? ? value_or_issuer : issuer
124
+ normalized = normalize_endpoint_url(name, value, issuer_value)
125
+
126
+ if trusted_origin && !trusted_origin.call(normalized)
127
+ raise DiscoveryError.new(
128
+ "discovery_untrusted_origin",
129
+ "The #{name} \"#{normalized}\" is not trusted by your trusted origins configuration.",
130
+ details: {endpoint: name, url: normalized}
131
+ )
132
+ end
133
+
134
+ normalized
135
+ end
136
+
137
+ def needs_runtime_discovery?(oidc_config)
138
+ config = BetterAuth::Plugins.normalize_hash(oidc_config || {})
139
+ config[:authorization_endpoint].to_s.empty? ||
140
+ config[:token_endpoint].to_s.empty? ||
141
+ config[:jwks_endpoint].to_s.empty?
142
+ end
143
+
144
+ def ensure_runtime_discovery(config, issuer, trusted_origin, fetch: nil, timeout: nil)
145
+ normalized = BetterAuth::Plugins.normalize_hash(config || {})
146
+ return config unless needs_runtime_discovery?(normalized)
147
+
148
+ discovered = discover_oidc_config(
149
+ issuer: issuer,
150
+ existing_config: normalized,
151
+ trusted_origin: trusted_origin,
152
+ fetch: fetch,
153
+ timeout: timeout
154
+ )
155
+ normalized.merge(
156
+ authorization_endpoint: discovered[:authorization_endpoint],
157
+ token_endpoint: discovered[:token_endpoint],
158
+ token_endpoint_authentication: discovered[:token_endpoint_authentication],
159
+ user_info_endpoint: discovered[:user_info_endpoint],
160
+ jwks_endpoint: discovered[:jwks_endpoint]
161
+ ).compact
162
+ end
163
+
164
+ def select_token_endpoint_auth_method(document_or_config = {}, existing_method = nil)
165
+ return existing_method if existing_method
166
+
167
+ config = BetterAuth::Plugins.normalize_hash(document_or_config || {})
168
+ return config[:token_endpoint_authentication] if config[:token_endpoint_authentication]
169
+
170
+ methods = config[:token_endpoint_auth_methods_supported] || config[:methods] || []
171
+ return "client_secret_post" if Array(methods).include?("client_secret_post") && !Array(methods).include?("client_secret_basic")
172
+
173
+ "client_secret_basic"
174
+ end
175
+
176
+ def parse_http_url!(url, name, details: {})
177
+ uri = URI.parse(url.to_s)
178
+ raise URI::InvalidURIError if uri.scheme.to_s.empty? || uri.host.to_s.empty?
179
+ unless %w[http https].include?(uri.scheme)
180
+ raise DiscoveryError.new(
181
+ "discovery_invalid_url",
182
+ "The url \"#{name}\" must use the http or https supported protocols",
183
+ details: details.merge(protocol: "#{uri.scheme}:")
184
+ )
185
+ end
186
+
187
+ uri
188
+ rescue URI::InvalidURIError
189
+ raise DiscoveryError.new(
190
+ "discovery_invalid_url",
191
+ "The url \"#{name}\" must be valid",
192
+ details: details
193
+ )
194
+ end
195
+
196
+ def normalize_endpoint_url(name, endpoint, issuer)
197
+ raw = endpoint.to_s
198
+ if raw.match?(%r{\Ahttps?://}i)
199
+ uri = parse_http_url!(raw, name, details: {endpoint: name, url: raw})
200
+ return uri.to_s
201
+ end
202
+
203
+ issuer_uri = parse_http_url!(issuer, name, details: {endpoint: name, url: raw})
204
+ issuer_base = issuer_uri.to_s.sub(%r{/+\z}, "")
205
+ endpoint_path = raw.sub(%r{\A/+}, "")
206
+ normalized = "#{issuer_base}/#{endpoint_path}"
207
+ parse_http_url!(normalized, name, details: {endpoint: name, url: normalized}).to_s
208
+ end
209
+
210
+ def parse_discovery_fetch_response(response)
211
+ if response.respond_to?(:code) && response.respond_to?(:body)
212
+ status = response.code.to_i
213
+ body = response.body
214
+ return parse_discovery_body(body) if status.between?(200, 299)
215
+
216
+ raise_discovery_http_error(status, response.message.to_s)
217
+ end
218
+
219
+ normalized = response.is_a?(Hash) ? BetterAuth::Plugins.normalize_hash(response) : {data: response}
220
+ error = normalized[:error]
221
+ if error
222
+ error_hash = BetterAuth::Plugins.normalize_hash(error)
223
+ raise_discovery_http_error(error_hash[:status].to_i, error_hash[:message].to_s)
224
+ end
225
+
226
+ data = normalized.key?(:data) ? normalized[:data] : normalized
227
+ parse_discovery_body(data)
228
+ end
229
+
230
+ def parse_discovery_body(data)
231
+ raise DiscoveryError.new("discovery_invalid_json", "OIDC discovery response was empty") if data.nil?
232
+ return BetterAuth::Plugins.normalize_hash(data) if data.is_a?(Hash)
233
+
234
+ parsed = JSON.parse(data.to_s)
235
+ raise JSON::ParserError if !parsed.is_a?(Hash)
236
+
237
+ BetterAuth::Plugins.normalize_hash(parsed)
238
+ rescue JSON::ParserError
239
+ raise DiscoveryError.new(
240
+ "discovery_invalid_json",
241
+ "OIDC discovery response was not valid JSON",
242
+ details: {bodyPreview: data.to_s[0, 200]}
243
+ )
244
+ end
245
+
246
+ def raise_discovery_http_error(status, message)
247
+ case status
248
+ when 404
249
+ raise DiscoveryError.new("discovery_not_found", "OIDC discovery endpoint was not found", details: {status: status, message: message})
250
+ when 408
251
+ raise DiscoveryError.new("discovery_timeout", "OIDC discovery request timed out", details: {status: status, message: message})
252
+ else
253
+ raise DiscoveryError.new("discovery_unexpected_error", "OIDC discovery request failed", details: {status: status, message: message})
254
+ end
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SSO
5
+ module OIDC
6
+ class DiscoveryError < StandardError
7
+ attr_reader :code, :details
8
+
9
+ def initialize(code, message, details: {})
10
+ @code = code
11
+ @details = details
12
+ super(message)
13
+ end
14
+ end
15
+
16
+ module Errors
17
+ module_function
18
+
19
+ def api_error(error)
20
+ return error if error.is_a?(APIError)
21
+
22
+ APIError.new("BAD_REQUEST", message: error.message)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SSO
5
+ module OIDC
6
+ module Types
7
+ DISCOVERY_ERROR_CODES = %w[
8
+ discovery_timeout
9
+ discovery_not_found
10
+ discovery_invalid_json
11
+ discovery_invalid_url
12
+ discovery_untrusted_origin
13
+ issuer_mismatch
14
+ discovery_incomplete
15
+ unsupported_token_auth_method
16
+ discovery_unexpected_error
17
+ ].freeze
18
+
19
+ REQUIRED_DISCOVERY_FIELDS = Discovery::REQUIRED_DISCOVERY_FIELDS
20
+
21
+ module_function
22
+
23
+ def discovery_error_code?(value)
24
+ DISCOVERY_ERROR_CODES.include?(value.to_s)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "oidc/discovery"
4
+ require_relative "oidc/errors"
5
+
6
+ module BetterAuth
7
+ module SSO
8
+ module OIDC
9
+ module_function
10
+
11
+ def discover_config(**kwargs)
12
+ Discovery.discover_oidc_config(**kwargs)
13
+ end
14
+
15
+ def needs_runtime_discovery?(oidc_config)
16
+ Discovery.needs_runtime_discovery?(oidc_config)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ SSO_DEFAULT_OIDC_HTTP_TIMEOUT = 10
6
+ SSO_DEFAULT_OIDC_HTTP_MAX_BODY_SIZE = 1024 * 1024
7
+ SSO_OIDC_PKCE_VERIFIER_KEY_PREFIX = "oidc-pkce-verifier:"
8
+ end
9
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def sso_discover_oidc_config(issuer:, fetch: nil, existing_config: nil, discovery_endpoint: nil, trusted_origin: nil, timeout: nil)
8
+ wrapped_fetch = sso_oidc_discovery_fetcher(fetch)
9
+ BetterAuth::SSO::OIDC::Discovery.discover_oidc_config(
10
+ issuer: issuer,
11
+ fetch: wrapped_fetch,
12
+ existing_config: existing_config,
13
+ discovery_endpoint: discovery_endpoint,
14
+ trusted_origin: trusted_origin,
15
+ timeout: timeout || SSO_DEFAULT_OIDC_HTTP_TIMEOUT
16
+ )
17
+ rescue BetterAuth::SSO::OIDC::DiscoveryError => error
18
+ raise BetterAuth::SSO::OIDC::Errors.api_error(error)
19
+ end
20
+
21
+ def sso_oidc_discovery_fetcher(fetch)
22
+ return nil unless fetch
23
+
24
+ ->(url, timeout: nil) do
25
+ accepts_keywords = fetch.parameters.any? { |kind, name| kind == :keyrest || (kind == :key && name == :timeout) }
26
+ accepts_keywords ? fetch.call(url, timeout: timeout) : fetch.call(url)
27
+ end
28
+ end
29
+
30
+ def sso_normalize_discovery_url(value, issuer, trusted_origin)
31
+ BetterAuth::SSO::OIDC::Discovery.normalize_url("url", value, issuer, trusted_origin)
32
+ rescue BetterAuth::SSO::OIDC::DiscoveryError => error
33
+ raise BetterAuth::SSO::OIDC::Errors.api_error(error)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,502 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def sso_verify_state(value, secret)
8
+ BetterAuth::Crypto.verify_jwt(value.to_s, secret)
9
+ rescue
10
+ nil
11
+ end
12
+
13
+ def sso_oidc_authorization_url(provider, ctx, state, plugin_config = {}, body = {})
14
+ config = sso_provider_config_hash(provider["oidcConfig"])
15
+ endpoint = config[:authorization_endpoint] || config[:authorization_url]
16
+ raise APIError.new("BAD_REQUEST", message: "Invalid OIDC configuration. Authorization URL not found.") if endpoint.to_s.empty?
17
+
18
+ scopes = Array(body[:scopes] || config[:scopes] || config[:scope] || ["openid", "email", "profile", "offline_access"])
19
+ query = {
20
+ client_id: config[:client_id],
21
+ response_type: "code",
22
+ redirect_uri: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
23
+ scope: scopes.join(" "),
24
+ state: state
25
+ }.compact
26
+ decoded_state = sso_decode_state(state, ctx.context.secret)
27
+ nonce = decoded_state&.fetch("nonce", nil)
28
+ query[:nonce] = nonce if nonce && !nonce.to_s.empty?
29
+ login_hint = body[:login_hint] || body[:email]
30
+ query[:login_hint] = login_hint if login_hint
31
+ code_challenge = decoded_state&.fetch("codeChallenge", nil)
32
+ if code_challenge
33
+ query[:code_challenge] = code_challenge
34
+ query[:code_challenge_method] = "S256"
35
+ end
36
+ "#{endpoint}?#{URI.encode_www_form(query)}"
37
+ end
38
+
39
+ def sso_saml_authorization_url(provider, relay_state, ctx = nil, config = {})
40
+ auth_request_url = config.dig(:saml, :auth_request_url)
41
+ if auth_request_url.respond_to?(:call)
42
+ return auth_request_url.call(provider: provider, relay_state: relay_state, context: ctx)
43
+ end
44
+
45
+ config = sso_provider_config_hash(provider["samlConfig"])
46
+ metadata = sso_saml_idp_metadata(config)
47
+ entry_point = config[:entry_point] || normalize_hash(sso_saml_preferred_service(metadata[:single_sign_on_service]) || {})[:location]
48
+ query = {
49
+ SAMLRequest: Base64.strict_encode64(JSON.generate({providerId: provider.fetch("providerId")})),
50
+ RelayState: relay_state
51
+ }
52
+ "#{entry_point}?#{URI.encode_www_form(query)}"
53
+ end
54
+
55
+ def sso_store_saml_authn_request(ctx, provider, url, config)
56
+ return if config.dig(:saml, :enable_in_response_to_validation) == false
57
+
58
+ request_id = sso_extract_saml_request_id(url)
59
+ return if request_id.to_s.empty?
60
+
61
+ ttl_ms = (config.dig(:saml, :request_ttl) || SSO_DEFAULT_AUTHN_REQUEST_TTL_MS).to_i
62
+ now_ms = (Time.now.to_f * 1000).to_i
63
+ expires_at_ms = now_ms + ttl_ms
64
+ record = {
65
+ id: request_id,
66
+ providerId: provider.fetch("providerId"),
67
+ createdAt: now_ms,
68
+ expiresAt: expires_at_ms
69
+ }
70
+ ctx.context.internal_adapter.create_verification_value(
71
+ identifier: "#{SSO_SAML_AUTHN_REQUEST_KEY_PREFIX}#{request_id}",
72
+ value: JSON.generate(record),
73
+ expiresAt: Time.at(expires_at_ms / 1000.0)
74
+ )
75
+ end
76
+
77
+ def sso_extract_saml_request_id(url)
78
+ query = URI.decode_www_form(URI.parse(url.to_s).query.to_s).to_h
79
+ encoded = query["SAMLRequest"]
80
+ return nil if encoded.to_s.empty?
81
+
82
+ xml = Zlib::Inflate.new(-Zlib::MAX_WBITS).inflate(Base64.decode64(encoded))
83
+ xml[/\bID=['"]([^'"]+)['"]/, 1]
84
+ rescue
85
+ nil
86
+ end
87
+
88
+ def sso_validate_saml_in_response_to(ctx, config, provider, raw_response, state)
89
+ return nil if config.dig(:saml, :enable_in_response_to_validation) == false
90
+
91
+ in_response_to = sso_extract_saml_in_response_to(raw_response)
92
+ if in_response_to && !in_response_to.empty?
93
+ identifier = "#{SSO_SAML_AUTHN_REQUEST_KEY_PREFIX}#{in_response_to}"
94
+ verification = ctx.context.internal_adapter.find_verification_value(identifier)
95
+ record = sso_parse_saml_authn_request_record(verification&.fetch("value", nil))
96
+ if !record || record["expiresAt"].to_i < (Time.now.to_f * 1000).to_i
97
+ return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "invalid_saml_response", "Unknown or expired request ID"))
98
+ end
99
+
100
+ if record["providerId"] != provider.fetch("providerId")
101
+ ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
102
+ return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "invalid_saml_response", "Provider mismatch"))
103
+ end
104
+
105
+ return {identifier: identifier}
106
+ elsif config.dig(:saml, :allow_idp_initiated) == false
107
+ return sso_redirect(ctx, sso_append_error(state["callbackURL"] || "/", "unsolicited_response", "IdP-initiated SSO not allowed"))
108
+ end
109
+
110
+ nil
111
+ end
112
+
113
+ def sso_consume_saml_in_response_to(ctx, result)
114
+ identifier = result.is_a?(Hash) ? result[:identifier] : nil
115
+ ctx.context.internal_adapter.delete_verification_by_identifier(identifier) unless identifier.to_s.empty?
116
+ end
117
+
118
+ def sso_parse_saml_authn_request_record(value)
119
+ JSON.parse(value.to_s)
120
+ rescue
121
+ nil
122
+ end
123
+
124
+ def sso_saml_assertion_replay_expires_at(assertion, config = {})
125
+ timestamp = sso_saml_timestamp_conditions(assertion)[:not_on_or_after]
126
+ parsed = Time.parse(timestamp.to_s) if timestamp
127
+ clock_skew_seconds = ((config.dig(:saml, :clock_skew) || SSO_DEFAULT_CLOCK_SKEW_MS).to_f / 1000.0)
128
+ return parsed + clock_skew_seconds if parsed && parsed + clock_skew_seconds > Time.now
129
+
130
+ ttl_ms = (config.dig(:saml, :assertion_ttl) || SSO_DEFAULT_ASSERTION_TTL_MS).to_i
131
+ Time.now + (ttl_ms / 1000.0)
132
+ rescue
133
+ Time.now + (SSO_DEFAULT_ASSERTION_TTL_MS / 1000.0)
134
+ end
135
+
136
+ def sso_extract_saml_in_response_to(raw_response)
137
+ xml = Base64.decode64(raw_response.to_s.gsub(/\s+/, ""))
138
+ xml[/\bInResponseTo=['"]([^'"]+)['"]/, 1]
139
+ rescue
140
+ nil
141
+ end
142
+
143
+ def sso_select_provider(ctx, body, config = {})
144
+ provider_id = body[:provider_id].to_s
145
+ issuer = body[:issuer].to_s
146
+ organization_slug = body[:organization_slug].to_s
147
+ domain = (body[:domain] || body[:email].to_s.split("@").last).to_s.downcase
148
+ if config[:default_sso]
149
+ provider = sso_default_provider(config, provider_id: provider_id, domain: domain)
150
+ return provider if provider
151
+ end
152
+
153
+ providers = ctx.context.adapter.find_many(model: "ssoProvider")
154
+ provider = if !provider_id.empty?
155
+ providers.find { |entry| entry["providerId"] == provider_id }
156
+ elsif !issuer.empty?
157
+ providers.find { |entry| entry["issuer"] == issuer }
158
+ elsif !organization_slug.empty?
159
+ organization = ctx.context.adapter.find_one(model: "organization", where: [{field: "slug", value: organization_slug}])
160
+ providers.find { |entry| entry["organizationId"] == organization&.fetch("id", nil) }
161
+ elsif !domain.empty?
162
+ providers.find { |entry| entry["domain"].to_s.downcase == domain } ||
163
+ providers.find { |entry| sso_email_domain_matches?(domain, entry["domain"]) }
164
+ end
165
+ raise APIError.new("NOT_FOUND", message: SSO_ERROR_CODES.fetch("PROVIDER_NOT_FOUND")) unless provider
166
+
167
+ provider
168
+ end
169
+
170
+ def sso_callback_provider(ctx, config, provider_id)
171
+ if config[:default_sso]
172
+ provider = sso_default_provider(config, provider_id: provider_id.to_s, domain: "")
173
+ return provider if provider
174
+ end
175
+
176
+ ctx.context.adapter.find_one(model: "ssoProvider", where: [{field: "providerId", value: provider_id.to_s}])
177
+ end
178
+
179
+ def sso_oidc_tokens(ctx, provider, oidc_config, state, plugin_config, raw_state: nil)
180
+ code_verifier = sso_oidc_code_verifier(ctx, raw_state || state["state"] || state[:state])
181
+ token_callback = oidc_config[:get_token]
182
+ if token_callback.respond_to?(:call)
183
+ return normalize_hash(token_callback.call(
184
+ code: ctx.query[:code] || ctx.query["code"],
185
+ codeVerifier: code_verifier,
186
+ redirectURI: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
187
+ provider: provider,
188
+ context: ctx
189
+ ))
190
+ end
191
+
192
+ token_endpoint = oidc_config[:token_endpoint]
193
+ return nil if token_endpoint.to_s.empty?
194
+
195
+ sso_exchange_oidc_code(
196
+ token_endpoint: token_endpoint,
197
+ code: ctx.query[:code] || ctx.query["code"],
198
+ code_verifier: code_verifier,
199
+ redirect_uri: sso_oidc_redirect_uri(ctx.context, provider.fetch("providerId")),
200
+ client_id: oidc_config[:client_id],
201
+ client_secret: oidc_config[:client_secret],
202
+ authentication: oidc_config[:token_endpoint_authentication],
203
+ timeout: plugin_config[:oidc_http_timeout],
204
+ max_body_size: plugin_config[:oidc_http_max_body_size]
205
+ )
206
+ rescue
207
+ nil
208
+ end
209
+
210
+ def sso_exchange_oidc_code(token_endpoint:, code:, code_verifier:, redirect_uri:, client_id:, client_secret:, authentication:, timeout: nil, max_body_size: nil)
211
+ uri = URI(token_endpoint.to_s)
212
+ request = Net::HTTP::Post.new(uri)
213
+ form = {
214
+ grant_type: "authorization_code",
215
+ code: code,
216
+ redirect_uri: redirect_uri,
217
+ client_id: client_id,
218
+ code_verifier: code_verifier
219
+ }.compact
220
+ if authentication.to_s == "client_secret_post"
221
+ form[:client_secret] = client_secret
222
+ elsif client_secret.to_s != ""
223
+ request.basic_auth(client_id.to_s, client_secret.to_s)
224
+ end
225
+ request.set_form_data(form)
226
+ response = Net::HTTP.start(
227
+ uri.hostname,
228
+ uri.port,
229
+ use_ssl: uri.scheme == "https",
230
+ open_timeout: sso_oidc_http_timeout(timeout),
231
+ read_timeout: sso_oidc_http_timeout(timeout)
232
+ ) { |http| http.request(request) }
233
+ return nil unless response.is_a?(Net::HTTPSuccess)
234
+ return nil if response.body.to_s.bytesize > sso_oidc_http_max_body_size(max_body_size)
235
+
236
+ normalize_hash(JSON.parse(response.body))
237
+ end
238
+
239
+ def sso_oidc_user_info(ctx, oidc_config, tokens, plugin_config, expected_nonce: nil)
240
+ user_callback = oidc_config[:get_user_info]
241
+ raw = if user_callback.respond_to?(:call)
242
+ user_callback.call(tokens)
243
+ elsif oidc_config[:user_info_endpoint]
244
+ sso_fetch_oidc_user_info(oidc_config[:user_info_endpoint], tokens[:access_token], timeout: plugin_config[:oidc_http_timeout], max_body_size: plugin_config[:oidc_http_max_body_size])
245
+ elsif tokens[:id_token]
246
+ return {_sso_error: "jwks_endpoint_not_found"} if oidc_config[:jwks_endpoint].to_s.empty?
247
+
248
+ sso_validate_oidc_id_token(
249
+ tokens[:id_token],
250
+ jwks_endpoint: oidc_config[:jwks_endpoint],
251
+ audience: oidc_config[:client_id],
252
+ issuer: oidc_config[:issuer],
253
+ fetch: plugin_config[:oidc_jwks_fetch],
254
+ expected_nonce: expected_nonce
255
+ ) || {_sso_error: "token_not_verified"}
256
+ else
257
+ {}
258
+ end
259
+ raw = normalize_hash(raw || {})
260
+ return raw if raw[:_sso_error]
261
+
262
+ mapping = normalize_hash(oidc_config[:mapping] || {})
263
+ extra_fields = normalize_hash(mapping[:extra_fields] || {}).each_with_object({}) do |(target, source), result|
264
+ result[target] = raw[normalize_key(source)] || raw[source.to_s]
265
+ end
266
+ extra_fields.merge(
267
+ id: raw[normalize_key(mapping[:id] || "sub")] || raw[:id],
268
+ email: raw[normalize_key(mapping[:email] || "email")],
269
+ email_verified: plugin_config[:trust_email_verified] ? raw[normalize_key(mapping[:email_verified] || "email_verified")] : false,
270
+ name: raw[normalize_key(mapping[:name] || "name")],
271
+ image: raw[normalize_key(mapping[:image] || "picture")]
272
+ )
273
+ end
274
+
275
+ def sso_fetch_oidc_user_info(endpoint, access_token, timeout: nil, max_body_size: nil)
276
+ uri = URI(endpoint.to_s)
277
+ request = Net::HTTP::Get.new(uri)
278
+ request["authorization"] = "Bearer #{access_token}"
279
+ response = Net::HTTP.start(
280
+ uri.hostname,
281
+ uri.port,
282
+ use_ssl: uri.scheme == "https",
283
+ open_timeout: sso_oidc_http_timeout(timeout),
284
+ read_timeout: sso_oidc_http_timeout(timeout)
285
+ ) { |http| http.request(request) }
286
+ return {} unless response.is_a?(Net::HTTPSuccess)
287
+ return {} if response.body.to_s.bytesize > sso_oidc_http_max_body_size(max_body_size)
288
+
289
+ JSON.parse(response.body)
290
+ rescue
291
+ {}
292
+ end
293
+
294
+ def sso_validate_oidc_id_token(token, jwks_endpoint:, audience:, issuer:, fetch: nil, expected_nonce: nil)
295
+ jwks = sso_fetch_oidc_jwks(jwks_endpoint, fetch: fetch)
296
+ payload, = ::JWT.decode(
297
+ token.to_s,
298
+ nil,
299
+ true,
300
+ algorithms: %w[RS256 RS384 RS512 ES256 ES384 ES512],
301
+ jwks: jwks,
302
+ aud: audience,
303
+ verify_aud: true,
304
+ iss: issuer,
305
+ verify_iss: true
306
+ )
307
+ if expected_nonce && !expected_nonce.to_s.empty?
308
+ token_nonce = payload["nonce"] || payload[:nonce]
309
+ return nil if token_nonce.to_s.empty?
310
+ return nil unless BetterAuth::Crypto.constant_time_compare(token_nonce.to_s, expected_nonce.to_s)
311
+ end
312
+ payload
313
+ rescue
314
+ nil
315
+ end
316
+
317
+ def sso_fetch_oidc_jwks(jwks_endpoint, fetch: nil)
318
+ if fetch.respond_to?(:call)
319
+ return normalize_hash(fetch.call(jwks_endpoint))
320
+ end
321
+
322
+ uri = URI(jwks_endpoint.to_s)
323
+ response = Net::HTTP.start(
324
+ uri.hostname,
325
+ uri.port,
326
+ use_ssl: uri.scheme == "https",
327
+ open_timeout: SSO_DEFAULT_OIDC_HTTP_TIMEOUT,
328
+ read_timeout: SSO_DEFAULT_OIDC_HTTP_TIMEOUT
329
+ ) { |http| http.get(uri.request_uri) }
330
+ return {} unless response.is_a?(Net::HTTPSuccess)
331
+ return {} if response.body.to_s.bytesize > SSO_DEFAULT_OIDC_HTTP_MAX_BODY_SIZE
332
+
333
+ normalize_hash(JSON.parse(response.body))
334
+ rescue
335
+ {}
336
+ end
337
+
338
+ def sso_decode_jwt_payload(token)
339
+ payload = token.to_s.split(".")[1]
340
+ return {} unless payload
341
+
342
+ JSON.parse(Base64.urlsafe_decode64(payload.ljust((payload.length + 3) & ~3, "=")))
343
+ rescue
344
+ {}
345
+ end
346
+
347
+ def sso_append_error(url, error, description = nil)
348
+ separator = url.to_s.include?("?") ? "&" : "?"
349
+ query = {error: error, error_description: description}.compact
350
+ "#{url}#{separator}#{URI.encode_www_form(query)}"
351
+ end
352
+
353
+ def sso_default_provider(config, provider_id:, domain:)
354
+ Array(config[:default_sso]).each do |raw_provider|
355
+ default_provider = normalize_hash(raw_provider)
356
+ next if !provider_id.empty? && default_provider[:provider_id].to_s != provider_id
357
+ next if provider_id.empty? && default_provider[:domain].to_s.downcase != domain
358
+
359
+ oidc_config = default_provider[:oidc_config] ? sso_storage_config(default_provider[:oidc_config]) : nil
360
+ saml_config = default_provider[:saml_config] ? sso_storage_config(default_provider[:saml_config]) : nil
361
+ return {
362
+ "issuer" => default_provider[:issuer] || default_provider.dig(:oidc_config, :issuer) || default_provider.dig(:saml_config, :issuer) || "",
363
+ "providerId" => default_provider.fetch(:provider_id),
364
+ "userId" => "default",
365
+ "domain" => default_provider[:domain],
366
+ "domainVerified" => true,
367
+ "oidcConfig" => oidc_config,
368
+ "samlConfig" => saml_config
369
+ }.compact
370
+ end
371
+ nil
372
+ end
373
+
374
+ def sso_oidc_pkce_state(provider)
375
+ return {} unless sso_provider_config_hash(provider["oidcConfig"])[:pkce]
376
+
377
+ verifier = BetterAuth::Crypto.random_string(128)
378
+ {
379
+ codeVerifier: verifier,
380
+ codeChallenge: sso_base64_urlsafe(OpenSSL::Digest::SHA256.digest(verifier))
381
+ }
382
+ end
383
+
384
+ def sso_store_oidc_pkce_verifier(ctx, state, verifier)
385
+ ctx.context.internal_adapter.create_verification_value(
386
+ identifier: "#{SSO_OIDC_PKCE_VERIFIER_KEY_PREFIX}#{state}",
387
+ value: verifier,
388
+ expiresAt: Time.now + 600
389
+ )
390
+ end
391
+
392
+ def sso_oidc_code_verifier(ctx, state)
393
+ return nil if state.to_s.empty?
394
+
395
+ identifier = "#{SSO_OIDC_PKCE_VERIFIER_KEY_PREFIX}#{state}"
396
+ verification = ctx.context.internal_adapter.find_verification_value(identifier)
397
+ ctx.context.internal_adapter.delete_verification_by_identifier(identifier) if verification
398
+ verification&.fetch("value", nil)
399
+ end
400
+
401
+ def sso_oidc_http_timeout(value)
402
+ timeout = value || SSO_DEFAULT_OIDC_HTTP_TIMEOUT
403
+ timeout.to_f.positive? ? timeout.to_f : SSO_DEFAULT_OIDC_HTTP_TIMEOUT
404
+ end
405
+
406
+ def sso_oidc_http_max_body_size(value)
407
+ size = value || SSO_DEFAULT_OIDC_HTTP_MAX_BODY_SIZE
408
+ size.to_i.positive? ? size.to_i : SSO_DEFAULT_OIDC_HTTP_MAX_BODY_SIZE
409
+ end
410
+
411
+ def sso_decode_state(state, secret)
412
+ BetterAuth::Crypto.verify_jwt(state.to_s, secret)
413
+ rescue
414
+ nil
415
+ end
416
+
417
+ def sso_base64_urlsafe(value)
418
+ Base64.strict_encode64(value).tr("+/", "-_").delete("=")
419
+ end
420
+
421
+ def sso_storage_config(config)
422
+ normalize_hash(config || {}).each_with_object({}) do |(key, value), result|
423
+ result[Schema.storage_key(key)] = value unless value.respond_to?(:call)
424
+ end
425
+ end
426
+
427
+ def sso_provider_limit(user, config)
428
+ limit = config[:providers_limit]
429
+ limit = 10 if limit.nil?
430
+ limit.respond_to?(:call) ? limit.call(user) : limit
431
+ end
432
+
433
+ def sso_validate_url!(value, message)
434
+ uri = URI(value.to_s)
435
+ unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
436
+ raise APIError.new("BAD_REQUEST", message: message)
437
+ end
438
+ rescue URI::InvalidURIError
439
+ raise APIError.new("BAD_REQUEST", message: message)
440
+ end
441
+
442
+ def sso_validate_organization_membership!(ctx, user_id, organization_id)
443
+ member = ctx.context.adapter.find_one(
444
+ model: "member",
445
+ where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}]
446
+ )
447
+ raise APIError.new("BAD_REQUEST", message: "You are not a member of the organization") unless member
448
+ end
449
+
450
+ def sso_hydrate_oidc_config(issuer, oidc_config, ctx)
451
+ existing = oidc_config.merge(issuer: issuer)
452
+ discovered = sso_discover_oidc_config(
453
+ issuer: issuer,
454
+ existing_config: existing,
455
+ fetch: ctx.context.options.plugins.find { |plugin| plugin.id == "sso" }&.options&.fetch(:oidc_discovery_fetch, nil),
456
+ trusted_origin: ->(url) { ctx.context.trusted_origin?(url, allow_relative_paths: false) },
457
+ timeout: ctx.context.options.plugins.find { |plugin| plugin.id == "sso" }&.options&.fetch(:oidc_http_timeout, nil)
458
+ )
459
+ existing.merge(discovered)
460
+ end
461
+
462
+ def sso_oidc_needs_runtime_discovery?(oidc_config)
463
+ config = normalize_hash(oidc_config || {})
464
+ config[:authorization_endpoint].to_s.empty? ||
465
+ config[:token_endpoint].to_s.empty?
466
+ end
467
+
468
+ def sso_ensure_runtime_oidc_provider(ctx, provider, plugin_config, require_jwks: false)
469
+ oidc_config = sso_provider_config_hash(provider["oidcConfig"])
470
+ needs_discovery = sso_oidc_needs_runtime_discovery?(oidc_config) || (require_jwks && oidc_config[:jwks_endpoint].to_s.empty?)
471
+ return provider if !needs_discovery
472
+
473
+ discovered = sso_discover_oidc_config(
474
+ issuer: provider.fetch("issuer"),
475
+ existing_config: oidc_config.merge(issuer: provider.fetch("issuer")),
476
+ fetch: plugin_config[:oidc_discovery_fetch],
477
+ trusted_origin: ->(url) { ctx.context.trusted_origin?(url, allow_relative_paths: false) },
478
+ timeout: plugin_config[:oidc_http_timeout]
479
+ )
480
+ provider.merge("oidcConfig" => oidc_config.merge(discovered))
481
+ end
482
+
483
+ def sso_validate_oidc_endpoint_origins!(ctx, oidc_config)
484
+ return unless sso_oidc_trusted_origin_enforced?(ctx)
485
+
486
+ config = normalize_hash(oidc_config || {})
487
+ %i[authorization_endpoint token_endpoint jwks_endpoint user_info_endpoint discovery_endpoint].each do |field|
488
+ url = config[field]
489
+ next if url.to_s.empty?
490
+
491
+ sso_validate_url!(url, "OIDC #{Schema.storage_key(field)} must be a valid URL")
492
+ next if ctx.context.trusted_origin?(url.to_s, allow_relative_paths: false)
493
+
494
+ raise APIError.new("BAD_REQUEST", message: "OIDC #{Schema.storage_key(field)} is not trusted")
495
+ end
496
+ end
497
+
498
+ def sso_oidc_trusted_origin_enforced?(ctx)
499
+ Array(ctx.context.trusted_origins).map(&:to_s).uniq.length > 1
500
+ end
501
+ end
502
+ end
metadata ADDED
@@ -0,0 +1,182 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: better_auth-oidc
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.10.0
5
+ platform: ruby
6
+ authors:
7
+ - Sebastian Sala
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: better_auth
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: base64
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0.2'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '1.0'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '0.2'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '1.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: jwt
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '2.8'
53
+ type: :runtime
54
+ prerelease: false
55
+ version_requirements: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - "~>"
58
+ - !ruby/object:Gem::Version
59
+ version: '2.8'
60
+ - !ruby/object:Gem::Dependency
61
+ name: logger
62
+ requirement: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '1.6'
67
+ - - "<"
68
+ - !ruby/object:Gem::Version
69
+ version: '2.0'
70
+ type: :runtime
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '1.6'
77
+ - - "<"
78
+ - !ruby/object:Gem::Version
79
+ version: '2.0'
80
+ - !ruby/object:Gem::Dependency
81
+ name: bundler
82
+ requirement: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - "~>"
85
+ - !ruby/object:Gem::Version
86
+ version: '2.5'
87
+ type: :development
88
+ prerelease: false
89
+ version_requirements: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: '2.5'
94
+ - !ruby/object:Gem::Dependency
95
+ name: minitest
96
+ requirement: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - "~>"
99
+ - !ruby/object:Gem::Version
100
+ version: '5.25'
101
+ type: :development
102
+ prerelease: false
103
+ version_requirements: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - "~>"
106
+ - !ruby/object:Gem::Version
107
+ version: '5.25'
108
+ - !ruby/object:Gem::Dependency
109
+ name: rake
110
+ requirement: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - "~>"
113
+ - !ruby/object:Gem::Version
114
+ version: '13.2'
115
+ type: :development
116
+ prerelease: false
117
+ version_requirements: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - "~>"
120
+ - !ruby/object:Gem::Version
121
+ version: '13.2'
122
+ - !ruby/object:Gem::Dependency
123
+ name: standardrb
124
+ requirement: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - "~>"
127
+ - !ruby/object:Gem::Version
128
+ version: '1.0'
129
+ type: :development
130
+ prerelease: false
131
+ version_requirements: !ruby/object:Gem::Requirement
132
+ requirements:
133
+ - - "~>"
134
+ - !ruby/object:Gem::Version
135
+ version: '1.0'
136
+ description: OpenID Connect relying party primitives and plugin extensions for Better
137
+ Auth Ruby enterprise SSO. Pair with better_auth-sso for provider management or require
138
+ directly for OIDC-only integrations.
139
+ email:
140
+ - sebastian.sala.tech@gmail.com
141
+ executables: []
142
+ extensions: []
143
+ extra_rdoc_files: []
144
+ files:
145
+ - CHANGELOG.md
146
+ - README.md
147
+ - lib/better_auth/oidc.rb
148
+ - lib/better_auth/oidc/version.rb
149
+ - lib/better_auth/plugins/oidc.rb
150
+ - lib/better_auth/sso/oidc.rb
151
+ - lib/better_auth/sso/oidc/discovery.rb
152
+ - lib/better_auth/sso/oidc/errors.rb
153
+ - lib/better_auth/sso/oidc/types.rb
154
+ - lib/better_auth/sso/plugin/oidc_core.rb
155
+ - lib/better_auth/sso/plugin/oidc_discovery.rb
156
+ - lib/better_auth/sso/plugin/oidc_runtime.rb
157
+ homepage: https://github.com/sebasxsala/better-auth-rb
158
+ licenses:
159
+ - MIT
160
+ metadata:
161
+ homepage_uri: https://github.com/sebasxsala/better-auth-rb
162
+ source_code_uri: https://github.com/sebasxsala/better-auth-rb
163
+ changelog_uri: https://github.com/sebasxsala/better-auth-rb/blob/main/packages/better_auth-oidc/CHANGELOG.md
164
+ bug_tracker_uri: https://github.com/sebasxsala/better-auth-rb/issues
165
+ rdoc_options: []
166
+ require_paths:
167
+ - lib
168
+ required_ruby_version: !ruby/object:Gem::Requirement
169
+ requirements:
170
+ - - ">="
171
+ - !ruby/object:Gem::Version
172
+ version: 3.2.0
173
+ required_rubygems_version: !ruby/object:Gem::Requirement
174
+ requirements:
175
+ - - ">="
176
+ - !ruby/object:Gem::Version
177
+ version: '0'
178
+ requirements: []
179
+ rubygems_version: 3.6.9
180
+ specification_version: 4
181
+ summary: Enterprise OIDC RP support for Better Auth Ruby SSO
182
+ test_files: []