better_auth-sso 0.1.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: 5e81ff737c06a26911ded4e339bc7195336d90076dbb52fce4394c346102aee7
4
+ data.tar.gz: 2b392bfba1385acd9a39a36421f6deed5a6ee92f986b8fadaa44204b08caf842
5
+ SHA512:
6
+ metadata.gz: 27461feafa4ef8fc32a3152b3335214ca7eedfe3ad3b19cffcd54731f484c08f5ba493fa9e823ff5ca737f9557ef646a4b055480ac746f8227581a6539126dac
7
+ data.tar.gz: a35620aa65758a5a74e0ef4efb8a2a8937a71e4774d48add776c5a883cc8a1710b253bb8e22f3d4fba98f1309793571b576106a2d8db35ed8fc621ac8c0fdfa2
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Initial package skeleton for Better Auth SSO.
data/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # Better Auth SSO
2
+
3
+ External SSO plugin package for `better_auth`.
4
+
5
+ SSO is the app-facing feature. It supports OIDC SSO and SAML SSO. SAML is not the same thing as SSO; SAML is one protocol used by SSO.
6
+
7
+ ```ruby
8
+ require "better_auth"
9
+ require "better_auth/sso"
10
+
11
+ BetterAuth.auth(
12
+ plugins: [
13
+ BetterAuth::Plugins.sso
14
+ ]
15
+ )
16
+ ```
17
+
18
+ SAML XML validation is included in this package and backed by `ruby-saml`:
19
+
20
+ ```ruby
21
+ require "better_auth/sso"
22
+
23
+ BetterAuth.auth(
24
+ plugins: [
25
+ BetterAuth::Plugins.sso(
26
+ BetterAuth::SSO::SAMLHooks.merge_options(
27
+ {},
28
+ BetterAuth::SSO::SAML.sso_options
29
+ )
30
+ )
31
+ ]
32
+ )
33
+ ```
34
+
35
+ SCIM is a separate provisioning feature and lives in `better_auth-scim`.
@@ -0,0 +1,649 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "net/http"
6
+ require "openssl"
7
+ require "securerandom"
8
+ require "uri"
9
+
10
+ module BetterAuth
11
+ module Plugins
12
+ module_function
13
+
14
+ remove_method :sso if method_defined?(:sso) || private_method_defined?(:sso)
15
+ singleton_class.remove_method(:sso) if singleton_class.method_defined?(:sso) || singleton_class.private_method_defined?(:sso)
16
+
17
+ SSO_ERROR_CODES = {
18
+ "PROVIDER_NOT_FOUND" => "No provider found",
19
+ "INVALID_STATE" => "Invalid state",
20
+ "SAML_RESPONSE_REPLAYED" => "SAML response has already been used"
21
+ }.freeze
22
+
23
+ SSO_SAML_SIGNATURE_ALGORITHMS = {
24
+ "rsa-sha1" => "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
25
+ "rsa-sha256" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
26
+ "rsa-sha384" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
27
+ "rsa-sha512" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
28
+ "ecdsa-sha256" => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
29
+ "ecdsa-sha384" => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
30
+ "ecdsa-sha512" => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512",
31
+ "sha1" => "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
32
+ "sha256" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
33
+ "sha384" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
34
+ "sha512" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512"
35
+ }.freeze
36
+
37
+ SSO_SAML_DIGEST_ALGORITHMS = {
38
+ "sha1" => "http://www.w3.org/2000/09/xmldsig#sha1",
39
+ "sha256" => "http://www.w3.org/2001/04/xmlenc#sha256",
40
+ "sha384" => "http://www.w3.org/2001/04/xmldsig-more#sha384",
41
+ "sha512" => "http://www.w3.org/2001/04/xmlenc#sha512"
42
+ }.freeze
43
+
44
+ SSO_SAML_SECURE_SIGNATURE_ALGORITHMS = (SSO_SAML_SIGNATURE_ALGORITHMS.values - ["http://www.w3.org/2000/09/xmldsig#rsa-sha1"]).uniq.freeze
45
+ SSO_SAML_SECURE_DIGEST_ALGORITHMS = (SSO_SAML_DIGEST_ALGORITHMS.values - ["http://www.w3.org/2000/09/xmldsig#sha1"]).uniq.freeze
46
+ SSO_SAML_SECURE_KEY_ENCRYPTION_ALGORITHMS = %w[
47
+ http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p
48
+ http://www.w3.org/2009/xmlenc11#rsa-oaep
49
+ ].freeze
50
+ SSO_SAML_SECURE_DATA_ENCRYPTION_ALGORITHMS = %w[
51
+ http://www.w3.org/2001/04/xmlenc#aes128-cbc
52
+ http://www.w3.org/2001/04/xmlenc#aes192-cbc
53
+ http://www.w3.org/2001/04/xmlenc#aes256-cbc
54
+ http://www.w3.org/2009/xmlenc11#aes128-gcm
55
+ http://www.w3.org/2009/xmlenc11#aes192-gcm
56
+ http://www.w3.org/2009/xmlenc11#aes256-gcm
57
+ ].freeze
58
+
59
+ def sso(options = {})
60
+ config = normalize_hash(options)
61
+ Plugin.new(
62
+ id: "sso",
63
+ init: ->(_ctx) { {options: {advanced: {disable_origin_check: ["/sso/saml2/callback", "/sso/saml2/sp/acs"]}}} },
64
+ schema: sso_schema(config),
65
+ endpoints: {
66
+ sp_metadata: sso_sp_metadata_endpoint,
67
+ register_sso_provider: sso_register_provider_endpoint,
68
+ sign_in_sso: sso_sign_in_endpoint(config),
69
+ callback_sso: sso_oidc_callback_endpoint,
70
+ callback_sso_saml: sso_saml_callback_endpoint(config),
71
+ acs_endpoint: sso_saml_acs_endpoint(config),
72
+ list_sso_providers: sso_list_providers_endpoint,
73
+ get_sso_provider: sso_get_provider_endpoint,
74
+ update_sso_provider: sso_update_provider_endpoint,
75
+ delete_sso_provider: sso_delete_provider_endpoint,
76
+ request_domain_verification: sso_request_domain_verification_endpoint(config),
77
+ verify_domain: sso_verify_domain_endpoint(config)
78
+ },
79
+ error_codes: SSO_ERROR_CODES,
80
+ options: config
81
+ )
82
+ end
83
+
84
+ def sso_schema(config = {})
85
+ {
86
+ ssoProvider: {
87
+ model_name: config[:model_name] || "ssoProviders",
88
+ fields: {
89
+ issuer: {type: "string", required: true},
90
+ oidcConfig: {type: "string", required: false},
91
+ samlConfig: {type: "string", required: false},
92
+ userId: {type: "string", required: true},
93
+ providerId: {type: "string", required: true, unique: true},
94
+ domain: {type: "string", required: true},
95
+ domainVerified: {type: "boolean", required: false, default_value: false},
96
+ domainVerificationToken: {type: "string", required: false},
97
+ organizationId: {type: "string", required: false}
98
+ }
99
+ }
100
+ }
101
+ end
102
+
103
+ def sso_discover_oidc_config(issuer:, fetch: nil, existing_config: nil, discovery_endpoint: nil, trusted_origin: nil, timeout: nil)
104
+ existing = normalize_hash(existing_config || {})
105
+ discovery_url = discovery_endpoint || existing[:discovery_endpoint] || "#{issuer.to_s.sub(%r{/+\z}, "")}/.well-known/openid-configuration"
106
+ if trusted_origin && !trusted_origin.call(discovery_url)
107
+ raise APIError.new("BAD_REQUEST", message: "OIDC discovery endpoint is not trusted")
108
+ end
109
+ document = if fetch
110
+ fetch.call(discovery_url)
111
+ else
112
+ uri = URI(discovery_url)
113
+ JSON.parse(Net::HTTP.get(uri))
114
+ end
115
+ document = normalize_hash(document)
116
+ valid = document[:issuer].to_s.sub(%r{/+\z}, "") == issuer.to_s.sub(%r{/+\z}, "") &&
117
+ !document[:authorization_endpoint].to_s.empty? &&
118
+ !document[:token_endpoint].to_s.empty? &&
119
+ !document[:jwks_uri].to_s.empty?
120
+ raise APIError.new("BAD_REQUEST", message: "Invalid OIDC discovery document") unless valid
121
+
122
+ authorization_endpoint = sso_normalize_discovery_url(document[:authorization_endpoint], issuer, trusted_origin)
123
+ token_endpoint = sso_normalize_discovery_url(document[:token_endpoint], issuer, trusted_origin)
124
+ jwks_endpoint = sso_normalize_discovery_url(document[:jwks_uri], issuer, trusted_origin)
125
+ user_info_endpoint = document[:userinfo_endpoint] && sso_normalize_discovery_url(document[:userinfo_endpoint], issuer, trusted_origin)
126
+ auth_methods = Array(document[:token_endpoint_auth_methods_supported])
127
+ token_endpoint_authentication = if existing[:token_endpoint_authentication]
128
+ existing[:token_endpoint_authentication]
129
+ elsif auth_methods.include?("client_secret_post") && !auth_methods.include?("client_secret_basic")
130
+ "client_secret_post"
131
+ else
132
+ "client_secret_basic"
133
+ end
134
+
135
+ {
136
+ issuer: existing[:issuer] || document[:issuer],
137
+ discovery_endpoint: existing[:discovery_endpoint] || discovery_url,
138
+ client_id: existing[:client_id],
139
+ authorization_endpoint: existing[:authorization_endpoint] || authorization_endpoint,
140
+ token_endpoint: existing[:token_endpoint] || token_endpoint,
141
+ jwks_endpoint: existing[:jwks_endpoint] || jwks_endpoint,
142
+ user_info_endpoint: existing[:user_info_endpoint] || user_info_endpoint,
143
+ token_endpoint_authentication: token_endpoint_authentication,
144
+ scopes_supported: existing[:scopes_supported] || document[:scopes_supported]
145
+ }.compact
146
+ rescue APIError
147
+ raise
148
+ rescue
149
+ raise APIError.new("BAD_REQUEST", message: "Invalid OIDC discovery document")
150
+ end
151
+
152
+ def sso_normalize_discovery_url(value, issuer, trusted_origin)
153
+ uri = URI(value.to_s)
154
+ normalized = if uri.absolute?
155
+ uri.to_s
156
+ else
157
+ issuer_uri = URI(issuer.to_s)
158
+ URI.join("#{issuer_uri.scheme}://#{issuer_uri.host}", value.to_s).to_s
159
+ end
160
+ if trusted_origin && !trusted_origin.call(normalized)
161
+ raise APIError.new("BAD_REQUEST", message: "OIDC discovery endpoint is not trusted")
162
+ end
163
+
164
+ normalized
165
+ rescue URI::InvalidURIError
166
+ raise APIError.new("BAD_REQUEST", message: "Invalid OIDC discovery document")
167
+ end
168
+
169
+ def sso_register_provider_endpoint
170
+ Endpoint.new(path: "/sso/register", method: "POST") do |ctx|
171
+ session = Routes.current_session(ctx)
172
+ body = normalize_hash(ctx.body)
173
+ provider_id = body[:provider_id].to_s
174
+ raise APIError.new("BAD_REQUEST", message: "providerId is required") if provider_id.empty?
175
+ if ctx.context.adapter.find_one(model: "ssoProvider", where: [{field: "providerId", value: provider_id}])
176
+ raise APIError.new("BAD_REQUEST", message: "Provider already exists")
177
+ end
178
+
179
+ provider = ctx.context.adapter.create(
180
+ model: "ssoProvider",
181
+ data: {
182
+ providerId: provider_id,
183
+ issuer: body[:issuer].to_s,
184
+ domain: body[:domain].to_s.downcase,
185
+ oidcConfig: body[:oidc_config],
186
+ samlConfig: body[:saml_config],
187
+ userId: session.fetch(:user).fetch("id"),
188
+ organizationId: body[:organization_id],
189
+ domainVerified: body[:domain_verified] || false
190
+ }
191
+ )
192
+ ctx.json(sso_sanitize_provider(provider, ctx.context))
193
+ end
194
+ end
195
+
196
+ def sso_list_providers_endpoint
197
+ Endpoint.new(path: "/sso/providers", method: "GET") do |ctx|
198
+ session = Routes.current_session(ctx)
199
+ providers = ctx.context.adapter.find_many(model: "ssoProvider")
200
+ .select { |provider| sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx) }
201
+ .map { |provider| sso_sanitize_provider(provider, ctx.context) }
202
+ ctx.json({providers: providers})
203
+ end
204
+ end
205
+
206
+ def sso_get_provider_endpoint
207
+ Endpoint.new(path: "/sso/providers/:providerId", method: "GET") do |ctx|
208
+ session = Routes.current_session(ctx)
209
+ provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
210
+ raise APIError.new("FORBIDDEN", message: "Access denied") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
211
+
212
+ ctx.json(sso_sanitize_provider(provider, ctx.context))
213
+ end
214
+ end
215
+
216
+ def sso_update_provider_endpoint
217
+ Endpoint.new(path: "/sso/providers/:providerId", method: "PATCH") do |ctx|
218
+ session = Routes.current_session(ctx)
219
+ provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
220
+ raise APIError.new("FORBIDDEN", message: "Access denied") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
221
+
222
+ body = normalize_hash(ctx.body)
223
+ update = {}
224
+ update[:issuer] = body[:issuer] if body.key?(:issuer)
225
+ update[:domain] = body[:domain].to_s.downcase if body.key?(:domain)
226
+ update[:domainVerified] = false if body.key?(:domain)
227
+ update[:oidcConfig] = body[:oidc_config] if body.key?(:oidc_config)
228
+ update[:samlConfig] = body[:saml_config] if body.key?(:saml_config)
229
+ updated = ctx.context.adapter.update(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}], update: update)
230
+ ctx.json(sso_sanitize_provider(updated, ctx.context))
231
+ end
232
+ end
233
+
234
+ def sso_delete_provider_endpoint
235
+ Endpoint.new(path: "/sso/providers/:providerId", method: "DELETE") do |ctx|
236
+ session = Routes.current_session(ctx)
237
+ provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
238
+ raise APIError.new("FORBIDDEN", message: "Access denied") unless sso_provider_access?(provider, session.fetch(:user).fetch("id"), ctx)
239
+
240
+ ctx.context.adapter.delete(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}])
241
+ ctx.json({success: true})
242
+ end
243
+ end
244
+
245
+ def sso_sign_in_endpoint(config = {})
246
+ Endpoint.new(path: "/sign-in/sso", method: "POST") do |ctx|
247
+ body = normalize_hash(ctx.body)
248
+ provider = sso_select_provider(ctx, body)
249
+ state_data = {
250
+ providerId: provider.fetch("providerId"),
251
+ callbackURL: body[:callback_url] || "/",
252
+ errorURL: body[:error_callback_url],
253
+ newUserURL: body[:new_user_callback_url],
254
+ requestSignUp: body[:request_sign_up]
255
+ }
256
+
257
+ if provider["samlConfig"]
258
+ relay_state = BetterAuth::Crypto.sign_jwt(state_data.merge(nonce: SecureRandom.hex(8)), ctx.context.secret, expires_in: 600)
259
+ url = sso_saml_authorization_url(provider, relay_state, ctx, config)
260
+ else
261
+ state = BetterAuth::Crypto.sign_jwt(state_data, ctx.context.secret, expires_in: 600)
262
+ url = sso_oidc_authorization_url(provider, ctx, state)
263
+ end
264
+ ctx.json({url: url, redirect: true})
265
+ end
266
+ end
267
+
268
+ def sso_oidc_callback_endpoint
269
+ Endpoint.new(path: "/sso/callback/:providerId", method: "GET") do |ctx|
270
+ state = sso_verify_state(ctx.query[:state] || ctx.query["state"], ctx.context.secret)
271
+ next ctx.redirect("#{ctx.context.base_url}/error?error=invalid_state") unless state
272
+
273
+ provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
274
+ oidc_config = normalize_hash(provider["oidcConfig"] || {})
275
+ token_callback = oidc_config[:get_token]
276
+ user_callback = oidc_config[:get_user_info]
277
+ tokens = token_callback ? token_callback.call(code: ctx.query[:code] || ctx.query["code"]) : {accessToken: "access-token"}
278
+ user_info = user_callback ? user_callback.call(tokens) : {}
279
+ user = sso_find_or_create_user(ctx, provider, user_info)
280
+ session = ctx.context.internal_adapter.create_session(user.fetch("id"))
281
+ Cookies.set_session_cookie(ctx, {session: session, user: user})
282
+ redirect_to = (state["newUserURL"] && !state["newUserURL"].to_s.empty?) ? state["newUserURL"] : state["callbackURL"]
283
+ sso_redirect(ctx, redirect_to || "/")
284
+ end
285
+ end
286
+
287
+ def sso_saml_callback_endpoint(config)
288
+ Endpoint.new(path: "/sso/saml2/callback/:providerId", method: ["GET", "POST"], metadata: {allowed_media_types: ["application/json", "application/x-www-form-urlencoded"]}) do |ctx|
289
+ sso_handle_saml_response(ctx, config)
290
+ end
291
+ end
292
+
293
+ def sso_saml_acs_endpoint(config)
294
+ Endpoint.new(path: "/sso/saml2/sp/acs/:providerId", method: "POST", metadata: {allowed_media_types: ["application/json", "application/x-www-form-urlencoded"]}) do |ctx|
295
+ sso_handle_saml_response(ctx, config)
296
+ end
297
+ end
298
+
299
+ def sso_sp_metadata_endpoint
300
+ Endpoint.new(path: "/sso/saml2/sp/metadata", method: "GET") do |ctx|
301
+ provider = sso_find_provider!(ctx, sso_fetch(ctx.query, :provider_id))
302
+ metadata = "<EntityDescriptor entityID=\"#{ctx.context.base_url}/sso/saml2/sp/metadata\"><SPSSODescriptor /></EntityDescriptor>"
303
+ if (ctx.query[:format] || ctx.query["format"]) == "json"
304
+ ctx.json({providerId: provider.fetch("providerId"), metadata: metadata})
305
+ else
306
+ ctx.set_header("content-type", "application/samlmetadata+xml")
307
+ ctx.json(metadata)
308
+ end
309
+ end
310
+ end
311
+
312
+ def sso_request_domain_verification_endpoint(config)
313
+ Endpoint.new(path: "/sso/request-domain-verification", method: "POST") do |ctx|
314
+ Routes.current_session(ctx)
315
+ provider = sso_find_provider!(ctx, normalize_hash(ctx.body)[:provider_id])
316
+ token = "_better-auth-sso-verification-#{provider.fetch("providerId")}-#{SecureRandom.hex(16)}"
317
+ updated = ctx.context.adapter.update(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}], update: {domainVerificationToken: token, domainVerified: false})
318
+ config.dig(:domain_verification, :request)&.call(provider: updated, token: token, context: ctx)
319
+ ctx.json({success: true, token: token}, status: 201)
320
+ end
321
+ end
322
+
323
+ def sso_verify_domain_endpoint(config)
324
+ Endpoint.new(path: "/sso/verify-domain", method: "POST") do |ctx|
325
+ Routes.current_session(ctx)
326
+ provider = sso_find_provider!(ctx, normalize_hash(ctx.body)[:provider_id])
327
+ token = provider["domainVerificationToken"].to_s
328
+ verifier = config.dig(:domain_verification, :verify)
329
+ verified = verifier ? verifier.call(domain: provider.fetch("domain"), token: token, provider: provider, context: ctx) : true
330
+ raise APIError.new("BAD_REQUEST", message: "Unable to verify domain ownership") unless verified
331
+
332
+ ctx.context.adapter.update(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}], update: {domainVerified: true, domainVerificationToken: nil})
333
+ ctx.json({success: true})
334
+ end
335
+ end
336
+
337
+ def sso_handle_saml_response(ctx, config = {})
338
+ provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
339
+ relay_state = sso_fetch(ctx.body, :relay_state) || sso_fetch(ctx.query, :relay_state)
340
+ state = sso_verify_state(relay_state, ctx.context.secret) || {}
341
+ assertion = sso_parse_saml_response(sso_fetch(ctx.body, :saml_response), config, provider, ctx)
342
+ sso_validate_saml_response!(config, assertion, provider, ctx)
343
+ assertion_id = assertion[:id] || assertion["id"] || assertion[:email]
344
+ replay_key = "sso-saml-assertion:#{provider.fetch("providerId")}:#{assertion_id}"
345
+ if ctx.context.internal_adapter.find_verification_value(replay_key)
346
+ raise APIError.new("BAD_REQUEST", message: SSO_ERROR_CODES.fetch("SAML_RESPONSE_REPLAYED"))
347
+ end
348
+ ctx.context.internal_adapter.create_verification_value(identifier: replay_key, value: "used", expiresAt: Time.now + 300)
349
+
350
+ user = sso_find_or_create_user(ctx, provider, assertion, config)
351
+ session = ctx.context.internal_adapter.create_session(user.fetch("id"))
352
+ Cookies.set_session_cookie(ctx, {session: session, user: user})
353
+ callback_url = state["callbackURL"] || "/"
354
+ callback_url = "/" unless ctx.context.trusted_origin?(callback_url, allow_relative_paths: true)
355
+ sso_redirect(ctx, callback_url)
356
+ end
357
+
358
+ def sso_find_or_create_user(ctx, provider, user_info, config = {})
359
+ user_info = normalize_hash(user_info)
360
+ email = user_info[:email].to_s.downcase
361
+ found = ctx.context.internal_adapter.find_user_by_email(email)
362
+ user = if found
363
+ found[:user]
364
+ else
365
+ created = ctx.context.internal_adapter.create_user(
366
+ email: email,
367
+ name: user_info[:name] || email,
368
+ emailVerified: user_info.key?(:email_verified) ? user_info[:email_verified] : true,
369
+ image: user_info[:image]
370
+ )
371
+ ctx.context.internal_adapter.create_account(
372
+ accountId: (user_info[:id] || created.fetch("id")).to_s,
373
+ providerId: "sso:#{provider.fetch("providerId")}",
374
+ userId: created.fetch("id")
375
+ )
376
+ created
377
+ end
378
+ sso_assign_organization_membership(ctx, provider, user, config)
379
+ user
380
+ end
381
+
382
+ def sso_validate_saml_response!(config, assertion, provider, ctx)
383
+ validator = config.dig(:saml, :validate_response)
384
+ return unless validator.respond_to?(:call)
385
+ return if validator.call(response: assertion, provider: provider, context: ctx)
386
+
387
+ raise APIError.new("BAD_REQUEST", message: "Invalid SAML response")
388
+ end
389
+
390
+ def sso_assign_organization_membership(ctx, provider, user, config)
391
+ organization_id = provider["organizationId"]
392
+ return if organization_id.to_s.empty?
393
+ return unless provider["domainVerified"]
394
+ return unless sso_email_domain_matches?(user["email"].to_s.split("@").last.to_s.downcase, provider["domain"])
395
+ return unless ctx.context.options.plugins.any? { |plugin| plugin.id == "organization" }
396
+ return if ctx.context.adapter.find_one(model: "member", where: [{field: "organizationId", value: organization_id}, {field: "userId", value: user.fetch("id")}])
397
+
398
+ role = config.dig(:organization_provisioning, :role) || "member"
399
+ ctx.context.adapter.create(model: "member", data: {organizationId: organization_id, userId: user.fetch("id"), role: role, createdAt: Time.now})
400
+ end
401
+
402
+ def sso_parse_saml_response(value, config = {}, provider = nil, ctx = nil)
403
+ parser = config.dig(:saml, :parse_response)
404
+ if parser.respond_to?(:call)
405
+ parsed = parser.call(raw_response: value.to_s, provider: provider, context: ctx)
406
+ return normalize_hash(parsed)
407
+ end
408
+
409
+ JSON.parse(Base64.decode64(value.to_s), symbolize_names: true)
410
+ rescue
411
+ raise APIError.new("BAD_REQUEST", message: "Invalid SAML response")
412
+ end
413
+
414
+ def sso_validate_single_saml_assertion!(saml_response)
415
+ xml = Base64.decode64(saml_response.to_s)
416
+ raise APIError.new("BAD_REQUEST", message: "Invalid base64-encoded SAML response") unless xml.include?("<")
417
+
418
+ assertions = xml.scan(/<(?:\w+:)?Assertion(?:\s|>|\/)/).length
419
+ encrypted_assertions = xml.scan(/<(?:\w+:)?EncryptedAssertion(?:\s|>|\/)/).length
420
+ total = assertions + encrypted_assertions
421
+ raise APIError.new("BAD_REQUEST", message: "SAML response contains no assertions") if total.zero?
422
+ if total > 1
423
+ raise APIError.new("BAD_REQUEST", message: "SAML response contains #{total} assertions, expected exactly 1")
424
+ end
425
+
426
+ true
427
+ rescue APIError
428
+ raise
429
+ rescue
430
+ raise APIError.new("BAD_REQUEST", message: "Invalid base64-encoded SAML response")
431
+ end
432
+
433
+ def sso_validate_saml_algorithms!(xml, options = {})
434
+ on_deprecated = (options[:on_deprecated] || "warn").to_s
435
+ signature_algorithms = xml.to_s.scan(/SignatureMethod[^>]+Algorithm=["']([^"']+)["']/).flatten.map { |algorithm| sso_normalize_saml_signature_algorithm(algorithm) }
436
+ digest_algorithms = xml.to_s.scan(/DigestMethod[^>]+Algorithm=["']([^"']+)["']/).flatten.map { |algorithm| sso_normalize_saml_digest_algorithm(algorithm) }
437
+ key_encryption_algorithms = xml.to_s.scan(/<[^\/>]*EncryptedKey\b[\s\S]*?EncryptionMethod[^>]+Algorithm=["']([^"']+)["']/).flatten
438
+ data_encryption_algorithms = xml.to_s.scan(/<[^\/>]*EncryptedData\b[\s\S]*?EncryptionMethod[^>]+Algorithm=["']([^"']+)["']/).flatten
439
+
440
+ sso_validate_saml_algorithm_group!(
441
+ signature_algorithms,
442
+ allowed: options[:allowed_signature_algorithms]&.map { |algorithm| sso_normalize_saml_signature_algorithm(algorithm) },
443
+ secure: SSO_SAML_SECURE_SIGNATURE_ALGORITHMS,
444
+ deprecated: ["http://www.w3.org/2000/09/xmldsig#rsa-sha1"],
445
+ on_deprecated: on_deprecated,
446
+ label: "signature"
447
+ )
448
+ sso_validate_saml_algorithm_group!(
449
+ digest_algorithms,
450
+ allowed: options[:allowed_digest_algorithms]&.map { |algorithm| sso_normalize_saml_digest_algorithm(algorithm) },
451
+ secure: SSO_SAML_SECURE_DIGEST_ALGORITHMS,
452
+ deprecated: ["http://www.w3.org/2000/09/xmldsig#sha1"],
453
+ on_deprecated: on_deprecated,
454
+ label: "digest"
455
+ )
456
+ sso_validate_saml_algorithm_group!(
457
+ key_encryption_algorithms,
458
+ allowed: options[:allowed_key_encryption_algorithms],
459
+ secure: SSO_SAML_SECURE_KEY_ENCRYPTION_ALGORITHMS,
460
+ deprecated: ["http://www.w3.org/2001/04/xmlenc#rsa-1_5"],
461
+ on_deprecated: on_deprecated,
462
+ label: "key encryption"
463
+ )
464
+ sso_validate_saml_algorithm_group!(
465
+ data_encryption_algorithms,
466
+ allowed: options[:allowed_data_encryption_algorithms],
467
+ secure: SSO_SAML_SECURE_DATA_ENCRYPTION_ALGORITHMS,
468
+ deprecated: ["http://www.w3.org/2001/04/xmlenc#tripledes-cbc"],
469
+ on_deprecated: on_deprecated,
470
+ label: "data encryption"
471
+ )
472
+
473
+ true
474
+ end
475
+
476
+ def sso_normalize_saml_signature_algorithm(algorithm)
477
+ SSO_SAML_SIGNATURE_ALGORITHMS.fetch(algorithm.to_s.downcase, algorithm.to_s)
478
+ end
479
+
480
+ def sso_normalize_saml_digest_algorithm(algorithm)
481
+ SSO_SAML_DIGEST_ALGORITHMS.fetch(algorithm.to_s.downcase, algorithm.to_s)
482
+ end
483
+
484
+ def sso_validate_saml_algorithm_group!(algorithms, allowed:, secure:, deprecated:, on_deprecated:, label:)
485
+ algorithms.each do |algorithm|
486
+ if allowed
487
+ next if allowed.include?(algorithm)
488
+
489
+ raise APIError.new("BAD_REQUEST", message: "SAML #{label} algorithm not in allow-list: #{algorithm}")
490
+ end
491
+
492
+ if deprecated.include?(algorithm)
493
+ raise APIError.new("BAD_REQUEST", message: "SAML response uses deprecated #{label} algorithm: #{algorithm}") if on_deprecated == "reject"
494
+ next
495
+ end
496
+ next if secure.include?(algorithm)
497
+
498
+ raise APIError.new("BAD_REQUEST", message: "SAML #{label} algorithm not recognized: #{algorithm}")
499
+ end
500
+ end
501
+
502
+ def sso_verify_state(value, secret)
503
+ BetterAuth::Crypto.verify_jwt(value.to_s, secret)
504
+ rescue
505
+ nil
506
+ end
507
+
508
+ def sso_oidc_authorization_url(provider, ctx, state)
509
+ config = normalize_hash(provider["oidcConfig"] || {})
510
+ endpoint = config[:authorization_endpoint] || config[:authorization_url]
511
+ query = {
512
+ client_id: config[:client_id],
513
+ response_type: "code",
514
+ redirect_uri: "#{ctx.context.base_url}/sso/callback/#{provider.fetch("providerId")}",
515
+ scope: Array(config[:scope] || config[:scopes] || ["openid", "email", "profile"]).join(" "),
516
+ state: state
517
+ }
518
+ "#{endpoint}?#{URI.encode_www_form(query)}"
519
+ end
520
+
521
+ def sso_saml_authorization_url(provider, relay_state, ctx = nil, config = {})
522
+ auth_request_url = config.dig(:saml, :auth_request_url)
523
+ if auth_request_url.respond_to?(:call)
524
+ return auth_request_url.call(provider: provider, relay_state: relay_state, context: ctx)
525
+ end
526
+
527
+ config = normalize_hash(provider["samlConfig"] || {})
528
+ query = {
529
+ SAMLRequest: Base64.strict_encode64(JSON.generate({providerId: provider.fetch("providerId")})),
530
+ RelayState: relay_state
531
+ }
532
+ "#{config[:entry_point]}?#{URI.encode_www_form(query)}"
533
+ end
534
+
535
+ def sso_select_provider(ctx, body)
536
+ providers = ctx.context.adapter.find_many(model: "ssoProvider")
537
+ provider = if body[:provider_id]
538
+ providers.find { |entry| entry["providerId"] == body[:provider_id].to_s }
539
+ elsif body[:issuer]
540
+ providers.find { |entry| entry["issuer"] == body[:issuer].to_s }
541
+ else
542
+ domain = body[:email].to_s.split("@").last.to_s.downcase
543
+ providers.find { |entry| sso_email_domain_matches?(domain, entry["domain"]) }
544
+ end
545
+ raise APIError.new("NOT_FOUND", message: SSO_ERROR_CODES.fetch("PROVIDER_NOT_FOUND")) unless provider
546
+
547
+ provider
548
+ end
549
+
550
+ def sso_email_domain_matches?(email_domain, provider_domain)
551
+ provider_domain.to_s.split(",").map { |value| value.strip.downcase }.reject(&:empty?).any? do |domain|
552
+ email_domain == domain || email_domain.end_with?(".#{domain}")
553
+ end
554
+ end
555
+
556
+ def sso_find_provider!(ctx, provider_id)
557
+ provider = ctx.context.adapter.find_one(model: "ssoProvider", where: [{field: "providerId", value: provider_id.to_s}])
558
+ raise APIError.new("NOT_FOUND", message: SSO_ERROR_CODES.fetch("PROVIDER_NOT_FOUND")) unless provider
559
+
560
+ provider
561
+ end
562
+
563
+ def sso_provider_access?(provider, user_id, ctx)
564
+ organization_id = provider["organizationId"]
565
+ return provider["userId"] == user_id if organization_id.to_s.empty?
566
+ return false unless ctx.context.options.plugins.any? { |plugin| plugin.id == "organization" }
567
+
568
+ member = ctx.context.adapter.find_one(
569
+ model: "member",
570
+ where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}]
571
+ )
572
+ Array(member&.fetch("role", nil).to_s.split(",")).map(&:strip).any? { |role| %w[owner admin].include?(role) }
573
+ end
574
+
575
+ def sso_sanitize_provider(provider, context)
576
+ data = provider.dup
577
+ oidc_config = normalize_hash(data["oidcConfig"] || {})
578
+ saml_config = normalize_hash(data["samlConfig"] || {})
579
+ data["type"] = saml_config.empty? ? "oidc" : "saml"
580
+ data["organizationId"] ||= nil
581
+ data["domainVerified"] = !!data["domainVerified"]
582
+ data["oidcConfig"] = oidc_config.empty? ? nil : sso_sanitize_oidc_config(oidc_config)
583
+ data["samlConfig"] = saml_config.empty? ? nil : sso_sanitize_saml_config(saml_config)
584
+ data["spMetadataUrl"] = "#{context.base_url}/sso/saml2/sp/metadata?providerId=#{URI.encode_www_form_component(data.fetch("providerId"))}"
585
+ data.compact
586
+ end
587
+
588
+ def sso_sanitize_config(config)
589
+ data = normalize_hash(config || {})
590
+ data.delete(:client_secret)
591
+ data.each_with_object({}) { |(key, value), result| result[Schema.storage_key(key)] = value unless value.respond_to?(:call) }
592
+ end
593
+
594
+ def sso_sanitize_oidc_config(config)
595
+ {
596
+ "clientIdLastFour" => sso_mask_client_id(config[:client_id]),
597
+ "authorizationEndpoint" => config[:authorization_endpoint],
598
+ "tokenEndpoint" => config[:token_endpoint],
599
+ "userInfoEndpoint" => config[:user_info_endpoint],
600
+ "jwksEndpoint" => config[:jwks_endpoint],
601
+ "scopes" => config[:scopes],
602
+ "tokenEndpointAuthentication" => config[:token_endpoint_authentication],
603
+ "pkce" => config[:pkce],
604
+ "discoveryEndpoint" => config[:discovery_endpoint]
605
+ }.compact
606
+ end
607
+
608
+ def sso_sanitize_saml_config(config)
609
+ {
610
+ "entryPoint" => config[:entry_point],
611
+ "callbackUrl" => config[:callback_url],
612
+ "audience" => config[:audience],
613
+ "wantAssertionsSigned" => config[:want_assertions_signed],
614
+ "identifierFormat" => config[:identifier_format],
615
+ "signatureAlgorithm" => config[:signature_algorithm],
616
+ "digestAlgorithm" => config[:digest_algorithm],
617
+ "certificate" => sso_parse_certificate(config[:cert])
618
+ }.compact
619
+ end
620
+
621
+ def sso_mask_client_id(client_id)
622
+ value = client_id.to_s
623
+ return "****" if value.length <= 4
624
+
625
+ "****#{value[-4, 4]}"
626
+ end
627
+
628
+ def sso_parse_certificate(cert)
629
+ OpenSSL::X509::Certificate.new(cert.to_s)
630
+ {subject: cert.to_s.lines.first.to_s.strip}
631
+ rescue
632
+ {error: "Failed to parse certificate"}
633
+ end
634
+
635
+ def sso_fetch(data, key)
636
+ compact = key.to_s.delete("_").downcase
637
+ data[key] ||
638
+ data[key.to_s] ||
639
+ data[Schema.storage_key(key)] ||
640
+ data[Schema.storage_key(key).to_sym] ||
641
+ data[compact] ||
642
+ data[compact.to_sym]
643
+ end
644
+
645
+ def sso_redirect(ctx, location)
646
+ [302, ctx.response_headers.merge("location" => location), [""]]
647
+ end
648
+ end
649
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "onelogin/ruby-saml"
5
+ require "uri"
6
+
7
+ module BetterAuth
8
+ module SSO
9
+ module SAML
10
+ module_function
11
+
12
+ DEFAULT_ATTRIBUTE_MAP = {
13
+ email: %w[email mail emailAddress Email EmailAddress],
14
+ name: %w[name displayName cn Name DisplayName],
15
+ given_name: %w[givenName firstName FirstName],
16
+ family_name: %w[familyName lastName LastName]
17
+ }.freeze
18
+
19
+ def sso_options(**options)
20
+ {
21
+ saml: {
22
+ auth_request_url: auth_request_url(**options),
23
+ parse_response: response_parser(**options)
24
+ }
25
+ }
26
+ end
27
+
28
+ def auth_request_url(settings: nil, request_options: {}, **_options)
29
+ lambda do |provider:, relay_state:, context:|
30
+ config = BetterAuth::Plugins.normalize_hash(provider["samlConfig"] || provider[:samlConfig] || {})
31
+ saml_settings = settings.respond_to?(:call) ? settings.call(provider: provider, context: context, saml_config: config) : build_settings(provider, context, config, settings)
32
+ OneLogin::RubySaml::Authrequest.new.create(saml_settings, {RelayState: relay_state}.merge(request_options))
33
+ end
34
+ end
35
+
36
+ def response_parser(settings: nil, response_options: {}, attribute_map: DEFAULT_ATTRIBUTE_MAP, **_options)
37
+ lambda do |raw_response:, provider:, context:|
38
+ config = BetterAuth::Plugins.normalize_hash(provider["samlConfig"] || provider[:samlConfig] || {})
39
+ saml_settings = settings.respond_to?(:call) ? settings.call(provider: provider, context: context, saml_config: config) : build_settings(provider, context, config, settings)
40
+ validate_response_xml!(raw_response, config)
41
+ response = OneLogin::RubySaml::Response.new(raw_response, {settings: saml_settings}.merge(response_options))
42
+ unless response.is_valid?
43
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: "Invalid SAML response")
44
+ end
45
+
46
+ attributes = response.attributes
47
+ email = first_attribute(attributes, attribute_map.fetch(:email)) || response.nameid
48
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: "Invalid SAML response") if email.to_s.empty?
49
+
50
+ given_name = first_attribute(attributes, attribute_map.fetch(:given_name))
51
+ family_name = first_attribute(attributes, attribute_map.fetch(:family_name))
52
+ name = first_attribute(attributes, attribute_map.fetch(:name)) || [given_name, family_name].compact.join(" ").strip
53
+ {
54
+ email: email.to_s.downcase,
55
+ name: name.to_s.empty? ? email.to_s : name.to_s,
56
+ id: assertion_identifier(response, email),
57
+ email_verified: true
58
+ }
59
+ end
60
+ end
61
+
62
+ def build_settings(provider, context, config, overrides = nil)
63
+ settings = overrides || OneLogin::RubySaml::Settings.new
64
+ provider_id = provider.fetch("providerId")
65
+ base_url = context.context.base_url
66
+ settings.assertion_consumer_service_url = config[:callback_url] || "#{base_url}/sso/saml2/sp/acs/#{provider_id}"
67
+ settings.sp_entity_id = config.dig(:sp_metadata, :entity_id) || config[:audience] || "#{base_url}/sso/saml2/sp/metadata?providerId=#{URI.encode_www_form_component(provider_id)}"
68
+ settings.idp_entity_id = provider["issuer"] || provider[:issuer]
69
+ settings.idp_sso_service_url = config[:entry_point]
70
+ settings.idp_cert = config[:cert] unless config[:cert].to_s.empty?
71
+ settings.name_identifier_format = config[:identifier_format] unless config[:identifier_format].to_s.empty?
72
+ settings.private_key = config[:sp_private_key] unless config[:sp_private_key].to_s.empty?
73
+ settings.certificate = config[:sp_certificate] unless config[:sp_certificate].to_s.empty?
74
+ settings.security[:want_assertions_signed] = config.fetch(:want_assertions_signed, true)
75
+ settings.security[:want_messages_signed] = config.fetch(:want_messages_signed, false)
76
+ settings.security[:want_assertions_encrypted] = config.fetch(:want_assertions_encrypted, false)
77
+ settings.security[:strict_audience_validation] = true
78
+ settings.security[:digest_method] = config[:digest_algorithm] || XMLSecurity::Document::SHA256
79
+ settings.security[:signature_method] = config[:signature_algorithm] || XMLSecurity::Document::RSA_SHA256
80
+ settings
81
+ end
82
+
83
+ def validate_response_xml!(raw_response, config)
84
+ BetterAuth::Plugins.sso_validate_single_saml_assertion!(raw_response)
85
+ xml = Base64.decode64(raw_response.to_s)
86
+ BetterAuth::Plugins.sso_validate_saml_algorithms!(
87
+ xml,
88
+ on_deprecated: config.fetch(:on_deprecated_algorithm, "reject"),
89
+ allowed_signature_algorithms: config[:allowed_signature_algorithms],
90
+ allowed_digest_algorithms: config[:allowed_digest_algorithms],
91
+ allowed_key_encryption_algorithms: config[:allowed_key_encryption_algorithms],
92
+ allowed_data_encryption_algorithms: config[:allowed_data_encryption_algorithms]
93
+ )
94
+ rescue BetterAuth::APIError
95
+ raise
96
+ rescue
97
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: "Invalid SAML response")
98
+ end
99
+
100
+ def first_attribute(attributes, names)
101
+ Array(names).each do |name|
102
+ value = attributes[name]
103
+ value = value.first if value.is_a?(Array)
104
+ return value unless value.to_s.empty?
105
+ end
106
+ nil
107
+ end
108
+
109
+ def assertion_identifier(response, email)
110
+ response.assertion_id || response.nameid || response.sessionindex || email
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SSO
5
+ module SAMLHooks
6
+ module_function
7
+
8
+ def merge_options(sso_options = {}, saml_options = {})
9
+ sso_options = BetterAuth::Plugins.normalize_hash(sso_options || {})
10
+ saml_options = BetterAuth::Plugins.normalize_hash(saml_options || {})
11
+ sso_options.merge(saml_options) do |key, old_value, new_value|
12
+ if key == :saml
13
+ BetterAuth::Plugins.normalize_hash(old_value || {}).merge(BetterAuth::Plugins.normalize_hash(new_value || {}))
14
+ else
15
+ new_value
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module SSO
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "better_auth"
4
+ require_relative "sso/version"
5
+ require_relative "sso/saml_hooks"
6
+ require_relative "sso/saml"
7
+ require_relative "plugins/sso"
8
+
9
+ module BetterAuth
10
+ module SSO
11
+ end
12
+ end
metadata ADDED
@@ -0,0 +1,162 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: better_auth-sso
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.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: ruby-saml
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '1.18'
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: 1.18.1
56
+ type: :runtime
57
+ prerelease: false
58
+ version_requirements: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - "~>"
61
+ - !ruby/object:Gem::Version
62
+ version: '1.18'
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: 1.18.1
66
+ - !ruby/object:Gem::Dependency
67
+ name: bundler
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - "~>"
71
+ - !ruby/object:Gem::Version
72
+ version: '2.5'
73
+ type: :development
74
+ prerelease: false
75
+ version_requirements: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '2.5'
80
+ - !ruby/object:Gem::Dependency
81
+ name: minitest
82
+ requirement: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - "~>"
85
+ - !ruby/object:Gem::Version
86
+ version: '5.25'
87
+ type: :development
88
+ prerelease: false
89
+ version_requirements: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: '5.25'
94
+ - !ruby/object:Gem::Dependency
95
+ name: rake
96
+ requirement: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - "~>"
99
+ - !ruby/object:Gem::Version
100
+ version: '13.2'
101
+ type: :development
102
+ prerelease: false
103
+ version_requirements: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - "~>"
106
+ - !ruby/object:Gem::Version
107
+ version: '13.2'
108
+ - !ruby/object:Gem::Dependency
109
+ name: standardrb
110
+ requirement: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - "~>"
113
+ - !ruby/object:Gem::Version
114
+ version: '1.0'
115
+ type: :development
116
+ prerelease: false
117
+ version_requirements: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - "~>"
120
+ - !ruby/object:Gem::Version
121
+ version: '1.0'
122
+ description: Adds SSO provider management, OIDC SSO, and SAML SSO integration for
123
+ Better Auth Ruby.
124
+ email:
125
+ - sebastian.sala.tech@gmail.com
126
+ executables: []
127
+ extensions: []
128
+ extra_rdoc_files: []
129
+ files:
130
+ - CHANGELOG.md
131
+ - README.md
132
+ - lib/better_auth/plugins/sso.rb
133
+ - lib/better_auth/sso.rb
134
+ - lib/better_auth/sso/saml.rb
135
+ - lib/better_auth/sso/saml_hooks.rb
136
+ - lib/better_auth/sso/version.rb
137
+ homepage: https://github.com/sebasxsala/better-auth
138
+ licenses:
139
+ - MIT
140
+ metadata:
141
+ homepage_uri: https://github.com/sebasxsala/better-auth
142
+ source_code_uri: https://github.com/sebasxsala/better-auth
143
+ changelog_uri: https://github.com/sebasxsala/better-auth/blob/main/packages/better_auth-sso/CHANGELOG.md
144
+ bug_tracker_uri: https://github.com/sebasxsala/better-auth/issues
145
+ rdoc_options: []
146
+ require_paths:
147
+ - lib
148
+ required_ruby_version: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: 3.2.0
153
+ required_rubygems_version: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - ">="
156
+ - !ruby/object:Gem::Version
157
+ version: '0'
158
+ requirements: []
159
+ rubygems_version: 3.6.9
160
+ specification_version: 4
161
+ summary: SSO plugin package for Better Auth Ruby
162
+ test_files: []