better_auth-sso 0.6.2 → 0.8.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.
@@ -66,9 +66,6 @@ module BetterAuth
66
66
 
67
67
  def organization_plugin?(ctx)
68
68
  context = ctx.context
69
- return context.hasPlugin("organization") if context.respond_to?(:hasPlugin)
70
- return context.has_plugin?("organization") if context.respond_to?(:has_plugin?)
71
-
72
69
  plugins = context.options.respond_to?(:plugins) ? context.options.plugins : []
73
70
  plugins.any? { |plugin| plugin.respond_to?(:id) && plugin.id == "organization" }
74
71
  end
@@ -0,0 +1,139 @@
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
+
70
+ def sso(options = {})
71
+ config = normalize_hash(options)
72
+ if defined?(BetterAuth::SSO::SAML) && defined?(BetterAuth::SSO::SAMLHooks)
73
+ config = BetterAuth::SSO::SAMLHooks.merge_options(BetterAuth::SSO::SAML.sso_options, config)
74
+ end
75
+ endpoints = BetterAuth::SSO::Routes::SSO.endpoints(config)
76
+ Plugin.new(
77
+ id: "sso",
78
+ init: ->(_ctx) { {options: {advanced: {disable_origin_check: ["/sso/saml2/callback", "/sso/saml2/sp/acs", "/sso/saml2/sp/slo"]}}} },
79
+ schema: BetterAuth::SSO::Routes::Schemas.plugin_schema(config),
80
+ endpoints: endpoints,
81
+ hooks: sso_hooks(config),
82
+ error_codes: SSO_ERROR_CODES,
83
+ options: config
84
+ )
85
+ end
86
+
87
+ def sso_hooks(config = {})
88
+ {
89
+ before: [
90
+ {
91
+ matcher: ->(ctx) { ctx.path == "/sign-out" },
92
+ handler: ->(ctx) { sso_before_sign_out(ctx, config) }
93
+ }
94
+ ],
95
+ after: [
96
+ {
97
+ matcher: ->(ctx) { ctx.path.to_s.match?(%r{\A/callback/[^/]+\z}) },
98
+ handler: ->(ctx) { sso_after_generic_callback(ctx, config) }
99
+ }
100
+ ]
101
+ }
102
+ end
103
+
104
+ def sso_before_sign_out(ctx, config = {})
105
+ return unless config.dig(:saml, :enable_single_logout)
106
+
107
+ token_cookie = ctx.context.auth_cookies[:session_token]
108
+ session_token = ctx.get_signed_cookie(token_cookie.name, ctx.context.secret)
109
+ return if session_token.to_s.empty?
110
+
111
+ lookup_key = "#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}"
112
+ session_lookup = ctx.context.internal_adapter.find_verification_value(lookup_key)
113
+ saml_session_key = session_lookup&.fetch("value", nil)
114
+ ctx.context.internal_adapter.delete_verification_by_identifier(saml_session_key) if saml_session_key
115
+ ctx.context.internal_adapter.delete_verification_by_identifier(lookup_key)
116
+ nil
117
+ rescue
118
+ nil
119
+ end
120
+
121
+ def sso_after_generic_callback(ctx, config = {})
122
+ new_session = ctx.context.new_session if ctx.context.respond_to?(:new_session)
123
+ return unless new_session && new_session[:user]
124
+ return unless defined?(BetterAuth::SSO::Linking::OrgAssignment)
125
+
126
+ BetterAuth::SSO::Linking::OrgAssignment.assign_organization_by_domain(
127
+ ctx,
128
+ user: new_session.fetch(:user),
129
+ provisioning_options: config[:organization_provisioning],
130
+ domain_verification: config[:domain_verification]
131
+ )
132
+ nil
133
+ end
134
+
135
+ def sso_schema(config = {})
136
+ BetterAuth::SSO::Routes::Schemas.plugin_schema(config)
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,151 @@
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: {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: {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: {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
+ sso_validate_saml_slo_signature!(ctx, raw_request, "Invalid LogoutRequest") if config.dig(:saml, :want_logout_request_signed)
48
+ logout_request_data = sso_process_saml_logout_request(ctx, provider, raw_request)
49
+ in_response_to = logout_request_data[:id].to_s.empty? ? "" : " InResponseTo=\"#{CGI.escapeHTML(logout_request_data[:id].to_s)}\""
50
+ 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>")
51
+ if sso_fetch(ctx.body, :saml_request)
52
+ next sso_saml_post_form(sso_saml_logout_destination(provider), "SAMLResponse", response, relay_state)
53
+ end
54
+
55
+ query = {SAMLResponse: response, RelayState: relay_state}
56
+ query = sso_signed_saml_redirect_query(provider, query) if config.dig(:saml, :want_logout_response_signed)
57
+ sso_redirect(ctx, "#{sso_saml_logout_destination(provider)}?#{URI.encode_www_form(query)}")
58
+ end
59
+ end
60
+
61
+ def sso_initiate_slo_endpoint(config = {})
62
+ Endpoint.new(path: "/sso/saml2/logout/:providerId", method: "POST") do |ctx|
63
+ raise APIError.new("BAD_REQUEST", message: "Single Logout is not enabled") unless config.dig(:saml, :enable_single_logout)
64
+
65
+ session = Routes.current_session(ctx)
66
+ provider = sso_find_provider!(ctx, sso_fetch(ctx.params, :provider_id))
67
+ destination = sso_saml_logout_destination(provider)
68
+ if destination.to_s.empty?
69
+ raise APIError.new("BAD_REQUEST", message: "IdP does not support Single Logout Service")
70
+ end
71
+
72
+ relay_state = sso_fetch(ctx.body, :callback_url) || ctx.context.base_url
73
+ session_token = session.fetch(:session).fetch("token")
74
+ user_email = session.fetch(:user).fetch("email")
75
+ saml_session_key = ctx.context.internal_adapter.find_verification_value("#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}")&.fetch("value")
76
+ saml_session = saml_session_key && ctx.context.internal_adapter.find_verification_value(saml_session_key)
77
+ saml_record = saml_session ? JSON.parse(saml_session.fetch("value")) : {}
78
+ name_id = saml_record["nameId"] || user_email
79
+ session_index = saml_record["sessionIndex"]
80
+
81
+ request_id = "_#{BetterAuth::Crypto.random_string(32)}"
82
+ session_index_xml = session_index.to_s.empty? ? "" : "<samlp:SessionIndex>#{CGI.escapeHTML(session_index.to_s)}</samlp:SessionIndex>"
83
+ 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>")
84
+ sso_store_saml_logout_request(ctx, provider, request_id, config)
85
+ ctx.context.internal_adapter.delete_verification_by_identifier(saml_session_key) if saml_session_key
86
+ ctx.context.internal_adapter.delete_verification_by_identifier("#{SSO_SAML_SESSION_BY_ID_KEY_PREFIX}#{session_token}")
87
+ ctx.context.internal_adapter.delete_session(session_token)
88
+ Cookies.delete_session_cookie(ctx)
89
+ query = {SAMLRequest: request, RelayState: relay_state}
90
+ query = sso_signed_saml_redirect_query(provider, query) if config.dig(:saml, :want_logout_request_signed)
91
+ sso_redirect(ctx, "#{destination}?#{URI.encode_www_form(query)}")
92
+ end
93
+ end
94
+
95
+ def sso_request_domain_verification_endpoint(config)
96
+ Endpoint.new(path: "/sso/request-domain-verification", method: "POST") do |ctx|
97
+ session = Routes.current_session(ctx)
98
+ provider = sso_find_provider!(ctx, normalize_hash(ctx.body)[:provider_id])
99
+ sso_authorize_domain_verification!(ctx, provider, session.fetch(:user).fetch("id"))
100
+ if provider.key?("domainVerified") && provider["domainVerified"]
101
+ raise APIError.new("CONFLICT", message: "Domain has already been verified", code: "DOMAIN_VERIFIED")
102
+ end
103
+
104
+ identifier = sso_domain_verification_identifier(config, provider.fetch("providerId"))
105
+ active = ctx.context.internal_adapter.find_verification_value(identifier)
106
+ if active && sso_future_time?(active.fetch("expiresAt"))
107
+ next ctx.json({domainVerificationToken: active.fetch("value")}, status: 201)
108
+ end
109
+
110
+ token = BetterAuth::Crypto.random_string(24)
111
+ ctx.context.internal_adapter.create_verification_value(identifier: identifier, value: token, expiresAt: Time.now + (7 * 24 * 60 * 60))
112
+ config.dig(:domain_verification, :request)&.call(provider: provider, token: token, context: ctx)
113
+ ctx.json({domainVerificationToken: token}, status: 201)
114
+ end
115
+ end
116
+
117
+ def sso_verify_domain_endpoint(config)
118
+ Endpoint.new(path: "/sso/verify-domain", method: "POST") do |ctx|
119
+ session = Routes.current_session(ctx)
120
+ provider = sso_find_provider!(ctx, normalize_hash(ctx.body)[:provider_id])
121
+ sso_authorize_domain_verification!(ctx, provider, session.fetch(:user).fetch("id"))
122
+ if provider.key?("domainVerified") && provider["domainVerified"]
123
+ raise APIError.new("CONFLICT", message: "Domain has already been verified", code: "DOMAIN_VERIFIED")
124
+ end
125
+
126
+ identifier = sso_domain_verification_identifier(config, provider.fetch("providerId"))
127
+ if identifier.length > 63
128
+ raise APIError.new("BAD_REQUEST", message: "Verification identifier exceeds the DNS label limit of 63 characters", code: "IDENTIFIER_TOO_LONG")
129
+ end
130
+ active = ctx.context.internal_adapter.find_verification_value(identifier)
131
+ if !active || !sso_future_time?(active.fetch("expiresAt"))
132
+ raise APIError.new("NOT_FOUND", message: "No pending domain verification exists", code: "NO_PENDING_VERIFICATION")
133
+ end
134
+
135
+ hostname = sso_hostname_from_domain(provider.fetch("domain"))
136
+ raise APIError.new("BAD_REQUEST", message: "Invalid domain", code: "INVALID_DOMAIN") if hostname.to_s.empty?
137
+
138
+ records = sso_resolve_txt_records("#{identifier}.#{hostname}", config)
139
+ expected = "#{identifier}=#{active.fetch("value")}"
140
+ unless sso_txt_record_exact_match?(records, expected)
141
+ raise APIError.new("BAD_GATEWAY", message: "Unable to verify domain ownership. Try again later", code: "DOMAIN_VERIFICATION_FAILED")
142
+ end
143
+
144
+ ctx.context.adapter.update(model: "ssoProvider", where: [{field: "id", value: provider.fetch("id")}], update: {domainVerified: true})
145
+ ctx.context.internal_adapter.delete_verification_by_identifier(identifier)
146
+ ctx.set_status(204)
147
+ nil
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,75 @@
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
+ existing = normalize_hash(existing_config || {})
9
+ discovery_url = discovery_endpoint || existing[:discovery_endpoint] || "#{issuer.to_s.sub(%r{/+\z}, "")}/.well-known/openid-configuration"
10
+ if trusted_origin && !trusted_origin.call(discovery_url)
11
+ raise APIError.new("BAD_REQUEST", message: "OIDC discovery endpoint is not trusted")
12
+ end
13
+ document = if fetch
14
+ fetch.call(discovery_url)
15
+ else
16
+ uri = URI(discovery_url)
17
+ JSON.parse(Net::HTTP.get(uri))
18
+ end
19
+ document = normalize_hash(document)
20
+ valid = document[:issuer].to_s.sub(%r{/+\z}, "") == issuer.to_s.sub(%r{/+\z}, "") &&
21
+ !document[:authorization_endpoint].to_s.empty? &&
22
+ !document[:token_endpoint].to_s.empty? &&
23
+ !document[:jwks_uri].to_s.empty?
24
+ raise APIError.new("BAD_REQUEST", message: "Invalid OIDC discovery document") unless valid
25
+
26
+ authorization_endpoint = sso_normalize_discovery_url(document[:authorization_endpoint], issuer, trusted_origin)
27
+ token_endpoint = sso_normalize_discovery_url(document[:token_endpoint], issuer, trusted_origin)
28
+ jwks_endpoint = sso_normalize_discovery_url(document[:jwks_uri], issuer, trusted_origin)
29
+ user_info_endpoint = document[:userinfo_endpoint] && sso_normalize_discovery_url(document[:userinfo_endpoint], issuer, trusted_origin)
30
+ auth_methods = Array(document[:token_endpoint_auth_methods_supported])
31
+ token_endpoint_authentication = if existing[:token_endpoint_authentication]
32
+ existing[:token_endpoint_authentication]
33
+ elsif auth_methods.include?("client_secret_post") && !auth_methods.include?("client_secret_basic")
34
+ "client_secret_post"
35
+ else
36
+ "client_secret_basic"
37
+ end
38
+
39
+ {
40
+ issuer: existing[:issuer] || document[:issuer],
41
+ discovery_endpoint: existing[:discovery_endpoint] || discovery_url,
42
+ client_id: existing[:client_id],
43
+ authorization_endpoint: existing[:authorization_endpoint] || authorization_endpoint,
44
+ token_endpoint: existing[:token_endpoint] || token_endpoint,
45
+ jwks_endpoint: existing[:jwks_endpoint] || jwks_endpoint,
46
+ user_info_endpoint: existing[:user_info_endpoint] || user_info_endpoint,
47
+ token_endpoint_authentication: token_endpoint_authentication,
48
+ scopes_supported: existing[:scopes_supported] || document[:scopes_supported]
49
+ }.compact
50
+ rescue APIError
51
+ raise
52
+ rescue
53
+ raise APIError.new("BAD_REQUEST", message: "Invalid OIDC discovery document")
54
+ end
55
+
56
+ def sso_normalize_discovery_url(value, issuer, trusted_origin)
57
+ uri = URI(value.to_s)
58
+ normalized = if uri.absolute?
59
+ uri.to_s
60
+ else
61
+ issuer_uri = URI(issuer.to_s)
62
+ issuer_base = issuer_uri.to_s.sub(%r{/+\z}, "")
63
+ endpoint = value.to_s.sub(%r{\A/+}, "")
64
+ "#{issuer_base}/#{endpoint}"
65
+ end
66
+ if trusted_origin && !trusted_origin.call(normalized)
67
+ raise APIError.new("BAD_REQUEST", message: "OIDC discovery endpoint is not trusted")
68
+ end
69
+
70
+ normalized
71
+ rescue URI::InvalidURIError
72
+ raise APIError.new("BAD_REQUEST", message: "Invalid OIDC discovery document")
73
+ end
74
+ end
75
+ end