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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +36 -2
- data/lib/better_auth/plugins/sso.rb +10 -1766
- data/lib/better_auth/sso/linking/org_assignment.rb +0 -3
- data/lib/better_auth/sso/plugin/core.rb +139 -0
- data/lib/better_auth/sso/plugin/endpoints.rb +151 -0
- data/lib/better_auth/sso/plugin/oidc_discovery.rb +75 -0
- data/lib/better_auth/sso/plugin/oidc_runtime.rb +420 -0
- data/lib/better_auth/sso/plugin/provider_utils.rb +216 -0
- data/lib/better_auth/sso/plugin/providers.rb +131 -0
- data/lib/better_auth/sso/plugin/saml_metadata_and_logout.rb +352 -0
- data/lib/better_auth/sso/plugin/saml_response.rb +150 -0
- data/lib/better_auth/sso/plugin/saml_validation_and_state.rb +183 -0
- data/lib/better_auth/sso/plugin/sign_in_and_oidc_callbacks.rb +125 -0
- data/lib/better_auth/sso/routes/schemas.rb +14 -8
- data/lib/better_auth/sso/routes/sso.rb +1 -1
- data/lib/better_auth/sso/saml_state.rb +1 -1
- data/lib/better_auth/sso/version.rb +1 -1
- metadata +27 -2
|
@@ -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
|