better_auth-sso 0.7.0 → 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.
@@ -0,0 +1,322 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ remove_method :sso if method_defined?(:sso) || private_method_defined?(:sso)
8
+ singleton_class.remove_method(:sso) if singleton_class.method_defined?(:sso) || singleton_class.private_method_defined?(:sso)
9
+
10
+ SSO_ERROR_CODES = {
11
+ "PROVIDER_NOT_FOUND" => "No provider found",
12
+ "INVALID_STATE" => "Invalid state",
13
+ "SAML_RESPONSE_REPLAYED" => "SAML response has already been used",
14
+ "SINGLE_LOGOUT_NOT_ENABLED" => "Single Logout is not enabled",
15
+ "INVALID_LOGOUT_REQUEST" => "Invalid LogoutRequest",
16
+ "INVALID_LOGOUT_RESPONSE" => "Invalid LogoutResponse",
17
+ "LOGOUT_FAILED_AT_IDP" => "Logout failed at IdP",
18
+ "IDP_SLO_NOT_SUPPORTED" => "IdP does not support Single Logout Service"
19
+ }.freeze
20
+
21
+ SSO_SAML_SIGNATURE_ALGORITHMS = {
22
+ "rsa-sha1" => "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
23
+ "rsa-sha256" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
24
+ "rsa-sha384" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
25
+ "rsa-sha512" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
26
+ "ecdsa-sha256" => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
27
+ "ecdsa-sha384" => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
28
+ "ecdsa-sha512" => "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512",
29
+ "sha1" => "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
30
+ "sha256" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
31
+ "sha384" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
32
+ "sha512" => "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512"
33
+ }.freeze
34
+
35
+ SSO_SAML_DIGEST_ALGORITHMS = {
36
+ "sha1" => "http://www.w3.org/2000/09/xmldsig#sha1",
37
+ "sha256" => "http://www.w3.org/2001/04/xmlenc#sha256",
38
+ "sha384" => "http://www.w3.org/2001/04/xmldsig-more#sha384",
39
+ "sha512" => "http://www.w3.org/2001/04/xmlenc#sha512"
40
+ }.freeze
41
+
42
+ SSO_SAML_SECURE_SIGNATURE_ALGORITHMS = (SSO_SAML_SIGNATURE_ALGORITHMS.values - ["http://www.w3.org/2000/09/xmldsig#rsa-sha1"]).uniq.freeze
43
+ SSO_SAML_SECURE_DIGEST_ALGORITHMS = (SSO_SAML_DIGEST_ALGORITHMS.values - ["http://www.w3.org/2000/09/xmldsig#sha1"]).uniq.freeze
44
+ SSO_SAML_SECURE_KEY_ENCRYPTION_ALGORITHMS = %w[
45
+ http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p
46
+ http://www.w3.org/2009/xmlenc11#rsa-oaep
47
+ ].freeze
48
+ SSO_SAML_SECURE_DATA_ENCRYPTION_ALGORITHMS = %w[
49
+ http://www.w3.org/2001/04/xmlenc#aes128-cbc
50
+ http://www.w3.org/2001/04/xmlenc#aes192-cbc
51
+ http://www.w3.org/2001/04/xmlenc#aes256-cbc
52
+ http://www.w3.org/2009/xmlenc11#aes128-gcm
53
+ http://www.w3.org/2009/xmlenc11#aes192-gcm
54
+ http://www.w3.org/2009/xmlenc11#aes256-gcm
55
+ ].freeze
56
+ SSO_DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024
57
+ SSO_DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024
58
+ SSO_SAML_RELAY_STATE_KEY_PREFIX = "saml-relay-state:"
59
+ SSO_SAML_AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:"
60
+ SSO_DEFAULT_AUTHN_REQUEST_TTL_MS = 5 * 60 * 1000
61
+ SSO_SAML_USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:"
62
+ SSO_DEFAULT_ASSERTION_TTL_MS = 15 * 60 * 1000
63
+ SSO_DEFAULT_CLOCK_SKEW_MS = 5 * 60 * 1000
64
+ SSO_SAML_SESSION_KEY_PREFIX = "saml-session:"
65
+ SSO_SAML_SESSION_BY_ID_KEY_PREFIX = "saml-session-by-id:"
66
+ SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX = "saml-logout-request:"
67
+ SSO_SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success"
68
+ SSO_DEFAULT_LOGOUT_REQUEST_TTL_MS = 5 * 60 * 1000
69
+ SSO_DEFAULT_OIDC_HTTP_TIMEOUT = 10
70
+ SSO_DEFAULT_OIDC_HTTP_MAX_BODY_SIZE = 1024 * 1024
71
+ SSO_OIDC_PKCE_VERIFIER_KEY_PREFIX = "oidc-pkce-verifier:"
72
+
73
+ def sso(options = {})
74
+ config = normalize_hash(options)
75
+ if defined?(BetterAuth::SSO::SAML) && defined?(BetterAuth::SSO::SAMLHooks)
76
+ config = BetterAuth::SSO::SAMLHooks.merge_options(BetterAuth::SSO::SAML.sso_options, config)
77
+ end
78
+ endpoints = BetterAuth::SSO::Routes::SSO.endpoints(config)
79
+ Plugin.new(
80
+ id: "sso",
81
+ init: ->(_ctx) { {options: {advanced: {disable_origin_check: ["/sso/saml2/callback", "/sso/saml2/sp/acs", "/sso/saml2/sp/slo"]}}} },
82
+ schema: BetterAuth::SSO::Routes::Schemas.plugin_schema(config),
83
+ endpoints: endpoints,
84
+ hooks: sso_hooks(config),
85
+ error_codes: SSO_ERROR_CODES,
86
+ options: config
87
+ )
88
+ end
89
+
90
+ def sso_hooks(config = {})
91
+ {
92
+ before: [
93
+ {
94
+ matcher: ->(ctx) { ctx.path == "/sign-out" },
95
+ handler: ->(ctx) { sso_before_sign_out(ctx, config) }
96
+ }
97
+ ],
98
+ after: [
99
+ {
100
+ matcher: ->(ctx) { ctx.path.to_s.match?(%r{\A/callback/[^/]+\z}) },
101
+ handler: ->(ctx) { sso_after_generic_callback(ctx, config) }
102
+ }
103
+ ]
104
+ }
105
+ end
106
+
107
+ def sso_before_sign_out(ctx, config = {})
108
+ return unless config.dig(:saml, :enable_single_logout)
109
+
110
+ token_cookie = ctx.context.auth_cookies[:session_token]
111
+ session_token = ctx.get_signed_cookie(token_cookie.name, ctx.context.secret)
112
+ return if session_token.to_s.empty?
113
+
114
+ lookup_key = "#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}"
115
+ session_lookup = ctx.context.internal_adapter.find_verification_value(lookup_key)
116
+ saml_session_key = session_lookup&.fetch("value", nil)
117
+ ctx.context.internal_adapter.delete_verification_by_identifier(saml_session_key) if saml_session_key
118
+ ctx.context.internal_adapter.delete_verification_by_identifier(lookup_key)
119
+ nil
120
+ rescue
121
+ nil
122
+ end
123
+
124
+ def sso_after_generic_callback(ctx, config = {})
125
+ new_session = ctx.context.new_session if ctx.context.respond_to?(:new_session)
126
+ return unless new_session && new_session[:user]
127
+ return unless defined?(BetterAuth::SSO::Linking::OrgAssignment)
128
+
129
+ BetterAuth::SSO::Linking::OrgAssignment.assign_organization_by_domain(
130
+ ctx,
131
+ user: new_session.fetch(:user),
132
+ provisioning_options: config[:organization_provisioning],
133
+ domain_verification: config[:domain_verification]
134
+ )
135
+ nil
136
+ end
137
+
138
+ def sso_schema(config = {})
139
+ BetterAuth::SSO::Routes::Schemas.plugin_schema(config)
140
+ end
141
+
142
+ def sso_openapi_for(route)
143
+ {
144
+ register_provider: sso_register_provider_openapi,
145
+ sign_in: sso_sign_in_openapi,
146
+ saml_callback: sso_saml_callback_openapi,
147
+ saml_acs: sso_saml_acs_openapi,
148
+ saml_slo: sso_saml_slo_openapi,
149
+ initiate_slo: sso_initiate_slo_openapi,
150
+ update_provider: sso_update_provider_openapi,
151
+ delete_provider: sso_delete_provider_openapi
152
+ }.fetch(route)
153
+ end
154
+
155
+ def sso_register_provider_openapi
156
+ {
157
+ openapi: {
158
+ description: "Register an SSO provider",
159
+ requestBody: OpenAPI.json_request_body(sso_provider_body_schema(required_fields: ["provider_id", "issuer", "domain"])),
160
+ responses: {
161
+ "200" => OpenAPI.json_response("SSO provider registered", sso_provider_response_schema)
162
+ }
163
+ }
164
+ }
165
+ end
166
+
167
+ def sso_sign_in_openapi
168
+ {
169
+ openapi: {
170
+ description: "Start an SSO sign-in flow",
171
+ requestBody: OpenAPI.json_request_body(
172
+ OpenAPI.object_schema(
173
+ {
174
+ provider_id: {type: "string", description: "SSO provider ID"},
175
+ domain: {type: "string", description: "Email domain used to select a provider"},
176
+ provider_type: {type: "string", enum: ["oidc", "saml"], description: "Preferred provider protocol"},
177
+ callback_url: {type: "string", description: "URL to redirect to after successful sign-in"},
178
+ error_callback_url: {type: "string", description: "URL to redirect to on sign-in error"},
179
+ new_user_callback_url: {type: "string", description: "URL to redirect to for new users"},
180
+ request_sign_up: {type: "boolean", description: "Whether the flow is requesting sign-up"}
181
+ }
182
+ )
183
+ ),
184
+ responses: {
185
+ "200" => OpenAPI.json_response("SSO sign-in URL", OpenAPI.object_schema({url: {type: "string"}, redirect: {type: "boolean"}}, required: ["url", "redirect"]))
186
+ }
187
+ }
188
+ }
189
+ end
190
+
191
+ def sso_saml_callback_openapi
192
+ {
193
+ openapi: {
194
+ description: "Handle a SAML identity provider callback",
195
+ requestBody: OpenAPI.json_request_body(sso_saml_message_schema, required: false),
196
+ responses: {
197
+ "200" => OpenAPI.json_response("SAML callback handled", {type: "object", additionalProperties: true})
198
+ }
199
+ }
200
+ }
201
+ end
202
+
203
+ def sso_saml_acs_openapi
204
+ {
205
+ openapi: {
206
+ description: "Handle a SAML assertion consumer service response",
207
+ requestBody: OpenAPI.json_request_body(sso_saml_message_schema, required: false),
208
+ responses: {
209
+ "200" => OpenAPI.json_response("SAML response handled", {type: "object", additionalProperties: true})
210
+ }
211
+ }
212
+ }
213
+ end
214
+
215
+ def sso_saml_slo_openapi
216
+ {
217
+ openapi: {
218
+ description: "Handle SAML single logout",
219
+ requestBody: OpenAPI.json_request_body(sso_saml_message_schema, required: false),
220
+ responses: {
221
+ "200" => OpenAPI.json_response("SAML single logout handled", {type: "object", additionalProperties: true})
222
+ }
223
+ }
224
+ }
225
+ end
226
+
227
+ def sso_initiate_slo_openapi
228
+ {
229
+ openapi: {
230
+ description: "Initiate SAML single logout",
231
+ requestBody: OpenAPI.json_request_body(
232
+ OpenAPI.object_schema(
233
+ {
234
+ callback_url: {type: "string", description: "URL to return to after logout"}
235
+ }
236
+ ),
237
+ required: false
238
+ ),
239
+ responses: {
240
+ "200" => OpenAPI.json_response("SAML logout initiated", {type: "object", additionalProperties: true})
241
+ }
242
+ }
243
+ }
244
+ end
245
+
246
+ def sso_update_provider_openapi
247
+ {
248
+ openapi: {
249
+ description: "Update an SSO provider",
250
+ requestBody: OpenAPI.json_request_body(sso_provider_body_schema(required_fields: [])),
251
+ responses: {
252
+ "200" => OpenAPI.json_response("SSO provider updated", sso_provider_response_schema)
253
+ }
254
+ }
255
+ }
256
+ end
257
+
258
+ def sso_delete_provider_openapi
259
+ {
260
+ openapi: {
261
+ description: "Delete an SSO provider",
262
+ requestBody: OpenAPI.json_request_body(
263
+ OpenAPI.object_schema(
264
+ {
265
+ provider_id: {type: "string", description: "SSO provider ID"}
266
+ }
267
+ )
268
+ ),
269
+ responses: {
270
+ "200" => OpenAPI.json_response("SSO provider deleted", OpenAPI.success_response_schema)
271
+ }
272
+ }
273
+ }
274
+ end
275
+
276
+ def sso_provider_body_schema(required_fields:)
277
+ OpenAPI.object_schema(
278
+ {
279
+ provider_id: {type: "string", description: "SSO provider ID"},
280
+ issuer: {type: "string", description: "SSO provider issuer URL"},
281
+ domain: {type: "string", description: "Email domain for the provider"},
282
+ oidc_config: {type: "object", additionalProperties: true, description: "OIDC provider configuration"},
283
+ saml_config: {type: "object", additionalProperties: true, description: "SAML provider configuration"},
284
+ organization_id: {type: "string", description: "Organization ID for this provider"},
285
+ override_user_info: {type: "boolean", description: "Whether to override OIDC user info with ID token claims"}
286
+ },
287
+ required: required_fields
288
+ )
289
+ end
290
+
291
+ def sso_provider_response_schema
292
+ OpenAPI.object_schema(
293
+ {
294
+ id: {type: "string"},
295
+ providerId: {type: "string"},
296
+ issuer: {type: "string"},
297
+ domain: {type: "string"},
298
+ oidcConfig: {type: ["object", "null"], additionalProperties: true},
299
+ samlConfig: {type: ["object", "null"], additionalProperties: true},
300
+ userId: {type: "string"},
301
+ organizationId: {type: ["string", "null"]},
302
+ domainVerified: {type: "boolean"},
303
+ redirectURI: {type: "string"},
304
+ domainVerificationToken: {type: "string"}
305
+ }
306
+ )
307
+ end
308
+
309
+ def sso_saml_message_schema
310
+ OpenAPI.object_schema(
311
+ {
312
+ SAMLResponse: {type: "string", description: "SAML response"},
313
+ SAMLRequest: {type: "string", description: "SAML logout request"},
314
+ RelayState: {type: "string", description: "SAML relay state"},
315
+ saml_response: {type: "string", description: "SAML response"},
316
+ saml_request: {type: "string", description: "SAML logout request"},
317
+ relay_state: {type: "string", description: "SAML relay state"}
318
+ }
319
+ )
320
+ end
321
+ end
322
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def sso_saml_callback_endpoint(config)
8
+ Endpoint.new(path: "/sso/saml2/callback/:providerId", method: ["GET", "POST"], metadata: sso_openapi_for(:saml_callback).merge(allowed_media_types: ["application/json", "application/x-www-form-urlencoded"])) do |ctx|
9
+ sso_handle_saml_response(ctx, config)
10
+ end
11
+ end
12
+
13
+ def sso_saml_acs_endpoint(config)
14
+ Endpoint.new(path: "/sso/saml2/sp/acs/:providerId", method: "POST", metadata: sso_openapi_for(:saml_acs).merge(allowed_media_types: ["application/json", "application/x-www-form-urlencoded"])) do |ctx|
15
+ sso_handle_saml_response(ctx, config)
16
+ end
17
+ end
18
+
19
+ def sso_sp_metadata_endpoint(config = {})
20
+ Endpoint.new(path: "/sso/saml2/sp/metadata", method: "GET") do |ctx|
21
+ provider = sso_find_provider!(ctx, sso_fetch(ctx.query, :provider_id))
22
+ metadata = sso_sp_metadata_xml(ctx, provider, config)
23
+ if (ctx.query[:format] || ctx.query["format"]) == "json"
24
+ ctx.json({providerId: provider.fetch("providerId"), metadata: metadata})
25
+ else
26
+ ctx.set_header("content-type", "application/samlmetadata+xml")
27
+ ctx.json(metadata)
28
+ end
29
+ end
30
+ end
31
+
32
+ def sso_saml_slo_endpoint(config = {})
33
+ Endpoint.new(path: "/sso/saml2/sp/slo/:providerId", method: ["GET", "POST"], metadata: sso_openapi_for(:saml_slo).merge(allowed_media_types: ["application/json", "application/x-www-form-urlencoded"])) do |ctx|
34
+ raise APIError.new("BAD_REQUEST", message: "Single Logout is not enabled") unless config.dig(:saml, :enable_single_logout)
35
+
36
+ provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
37
+ relay_state = sso_fetch(ctx.body, :relay_state) || sso_fetch(ctx.query, :relay_state)
38
+ if sso_fetch(ctx.body, :saml_response) || sso_fetch(ctx.query, :saml_response)
39
+ raw_response = sso_fetch(ctx.body, :saml_response) || sso_fetch(ctx.query, :saml_response)
40
+ sso_validate_saml_slo_signature!(ctx, raw_response, "Invalid LogoutResponse") if config.dig(:saml, :want_logout_response_signed)
41
+ sso_process_saml_logout_response(ctx, raw_response)
42
+ Cookies.delete_session_cookie(ctx)
43
+ next sso_redirect(ctx, sso_safe_slo_redirect_url(ctx, relay_state, provider.fetch("providerId")))
44
+ end
45
+
46
+ raw_request = sso_fetch(ctx.body, :saml_request) || sso_fetch(ctx.query, :saml_request)
47
+ raise APIError.new("BAD_REQUEST", message: "Invalid LogoutRequest") if raw_request.to_s.empty?
48
+
49
+ sso_validate_saml_slo_signature!(ctx, raw_request, "Invalid LogoutRequest") if config.dig(:saml, :want_logout_request_signed)
50
+ logout_request_data = sso_process_saml_logout_request(ctx, provider, raw_request)
51
+ in_response_to = logout_request_data[:id].to_s.empty? ? "" : " InResponseTo=\"#{CGI.escapeHTML(logout_request_data[:id].to_s)}\""
52
+ response = Base64.strict_encode64("<samlp:LogoutResponse xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" ID=\"_#{BetterAuth::Crypto.random_string(32)}\"#{in_response_to} Version=\"2.0\" IssueInstant=\"#{Time.now.utc.iso8601}\" Destination=\"#{sso_saml_logout_destination(provider)}\"><samlp:Status><samlp:StatusCode Value=\"urn:oasis:names:tc:SAML:2.0:status:Success\"/></samlp:Status></samlp:LogoutResponse>")
53
+ if sso_fetch(ctx.body, :saml_request)
54
+ next sso_saml_post_form(sso_saml_logout_destination(provider), "SAMLResponse", response, relay_state)
55
+ end
56
+
57
+ query = {SAMLResponse: response, RelayState: relay_state}
58
+ query = sso_signed_saml_redirect_query(provider, query) if config.dig(:saml, :want_logout_response_signed)
59
+ sso_redirect(ctx, "#{sso_saml_logout_destination(provider)}?#{URI.encode_www_form(query)}")
60
+ end
61
+ end
62
+
63
+ def sso_initiate_slo_endpoint(config = {})
64
+ Endpoint.new(path: "/sso/saml2/logout/:providerId", method: "POST", metadata: sso_openapi_for(:initiate_slo)) do |ctx|
65
+ raise APIError.new("BAD_REQUEST", message: "Single Logout is not enabled") unless config.dig(:saml, :enable_single_logout)
66
+
67
+ session = Routes.current_session(ctx)
68
+ provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
69
+ destination = sso_saml_logout_destination(provider)
70
+ if destination.to_s.empty?
71
+ raise APIError.new("BAD_REQUEST", message: "IdP does not support Single Logout Service")
72
+ end
73
+
74
+ relay_state = sso_fetch(ctx.body, :callback_url) || ctx.context.base_url
75
+ session_token = session.fetch(:session).fetch("token")
76
+ user_email = session.fetch(:user).fetch("email")
77
+ saml_session_key = ctx.context.internal_adapter.find_verification_value("#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}")&.fetch("value")
78
+ saml_session = saml_session_key && ctx.context.internal_adapter.find_verification_value(saml_session_key)
79
+ saml_record = saml_session ? JSON.parse(saml_session.fetch("value")) : {}
80
+ name_id = saml_record["nameId"] || user_email
81
+ session_index = saml_record["sessionIndex"]
82
+
83
+ request_id = "_#{BetterAuth::Crypto.random_string(32)}"
84
+ session_index_xml = session_index.to_s.empty? ? "" : "<samlp:SessionIndex>#{CGI.escapeHTML(session_index.to_s)}</samlp:SessionIndex>"
85
+ request = Base64.strict_encode64("<samlp:LogoutRequest xmlns:samlp=\"urn:oasis:names:tc:SAML:2.0:protocol\" xmlns:saml=\"urn:oasis:names:tc:SAML:2.0:assertion\" ID=\"#{request_id}\" Version=\"2.0\" IssueInstant=\"#{Time.now.utc.iso8601}\" Destination=\"#{CGI.escapeHTML(destination.to_s)}\"><saml:NameID>#{CGI.escapeHTML(name_id.to_s)}</saml:NameID>#{session_index_xml}</samlp:LogoutRequest>")
86
+ sso_store_saml_logout_request(ctx, provider, request_id, config)
87
+ ctx.context.internal_adapter.delete_verification_by_identifier(saml_session_key) if saml_session_key
88
+ ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}")
89
+ ctx.context.internal_adapter.delete_session(session_token)
90
+ Cookies.delete_session_cookie(ctx)
91
+ query = {SAMLRequest: request, RelayState: relay_state}
92
+ query = sso_signed_saml_redirect_query(provider, query) if config.dig(:saml, :want_logout_request_signed)
93
+ sso_redirect(ctx, "#{destination}?#{URI.encode_www_form(query)}")
94
+ end
95
+ end
96
+
97
+ def sso_request_domain_verification_endpoint(config)
98
+ Endpoint.new(path: "/sso/request-domain-verification", method: "POST") do |ctx|
99
+ session = Routes.current_session(ctx)
100
+ provider = sso_find_provider!(ctx, normalize_hash(ctx.body)[:provider_id])
101
+ sso_authorize_domain_verification!(ctx, provider, session.fetch(:user).fetch("id"))
102
+ if provider.key?("domainVerified") && provider["domainVerified"]
103
+ raise APIError.new("CONFLICT", message: "Domain has already been verified", code: "DOMAIN_VERIFIED")
104
+ end
105
+
106
+ identifier = sso_domain_verification_identifier(config, provider.fetch("providerId"))
107
+ active = ctx.context.internal_adapter.find_verification_value(identifier)
108
+ if active && sso_future_time?(active.fetch("expiresAt"))
109
+ next ctx.json({domainVerificationToken: active.fetch("value")}, status: 201)
110
+ end
111
+
112
+ token = BetterAuth::Crypto.random_string(24)
113
+ ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: token, expiresAt: Time.now + (7 * 24 * 60 * 60))
114
+ config.dig(:domain_verification, :request)&.call(provider: provider, token: token, context: ctx)
115
+ ctx.json({domainVerificationToken: token}, status: 201)
116
+ end
117
+ end
118
+
119
+ def sso_verify_domain_endpoint(config)
120
+ Endpoint.new(path: "/sso/verify-domain", method: "POST") do |ctx|
121
+ session = Routes.current_session(ctx)
122
+ provider = sso_find_provider!(ctx, normalize_hash(ctx.body)[:provider_id])
123
+ sso_authorize_domain_verification!(ctx, provider, session.fetch(:user).fetch("id"))
124
+ if provider.key?("domainVerified") && provider["domainVerified"]
125
+ raise APIError.new("CONFLICT", message: "Domain has already been verified", code: "DOMAIN_VERIFIED")
126
+ end
127
+
128
+ identifier = sso_domain_verification_identifier(config, provider.fetch("providerId"))
129
+ if identifier.length > 63
130
+ raise APIError.new("BAD_REQUEST", message: "Verification identifier exceeds the DNS label limit of 63 characters", code: "IDENTIFIER_TOO_LONG")
131
+ end
132
+ active = ctx.context.internal_adapter.find_verification_value(identifier)
133
+ if !active || !sso_future_time?(active.fetch("expiresAt"))
134
+ raise APIError.new("NOT_FOUND", message: "No pending domain verification exists", code: "NO_PENDING_VERIFICATION")
135
+ end
136
+
137
+ hostname = sso_hostname_from_domain(provider.fetch("domain"))
138
+ raise APIError.new("BAD_REQUEST", message: "Invalid domain", code: "INVALID_DOMAIN") if hostname.to_s.empty?
139
+
140
+ records = sso_resolve_txt_records("#{identifier}.#{hostname}", config)
141
+ expected = "#{identifier}=#{active.fetch("value")}"
142
+ unless sso_txt_record_exact_match?(records, expected)
143
+ raise APIError.new("BAD_GATEWAY", message: "Unable to verify domain ownership. Try again later", code: "DOMAIN_VERIFICATION_FAILED")
144
+ end
145
+
146
+ ctx.context.adapter.update(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}], update: {domainVerified: true})
147
+ ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
148
+ ctx.set_status(204)
149
+ nil
150
+ end
151
+ end
152
+ end
153
+ 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