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 +7 -0
- data/CHANGELOG.md +9 -0
- data/README.md +25 -0
- data/lib/better_auth/oidc/version.rb +7 -0
- data/lib/better_auth/oidc.rb +11 -0
- data/lib/better_auth/plugins/oidc.rb +16 -0
- data/lib/better_auth/sso/oidc/discovery.rb +259 -0
- data/lib/better_auth/sso/oidc/errors.rb +27 -0
- data/lib/better_auth/sso/oidc/types.rb +29 -0
- data/lib/better_auth/sso/oidc.rb +20 -0
- data/lib/better_auth/sso/plugin/oidc_core.rb +9 -0
- data/lib/better_auth/sso/plugin/oidc_discovery.rb +36 -0
- data/lib/better_auth/sso/plugin/oidc_runtime.rb +502 -0
- metadata +182 -0
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
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,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,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: []
|