better_auth-sso 0.2.0 → 0.5.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/lib/better_auth/plugins/sso.rb +305 -73
- data/lib/better_auth/sso/client.rb +31 -0
- data/lib/better_auth/sso/constants.rb +20 -0
- data/lib/better_auth/sso/domain_verification.rb +17 -0
- data/lib/better_auth/sso/linking/org_assignment.rb +118 -0
- data/lib/better_auth/sso/linking/types.rb +52 -0
- data/lib/better_auth/sso/linking.rb +24 -0
- data/lib/better_auth/sso/oidc/discovery.rb +259 -0
- data/lib/better_auth/sso/oidc/errors.rb +27 -0
- data/lib/better_auth/sso/oidc/types.rb +29 -0
- data/lib/better_auth/sso/oidc.rb +20 -0
- data/lib/better_auth/sso/routes/domain_verification.rb +19 -0
- data/lib/better_auth/sso/routes/helpers.rb +19 -0
- data/lib/better_auth/sso/routes/providers.rb +19 -0
- data/lib/better_auth/sso/routes/saml_pipeline.rb +19 -0
- data/lib/better_auth/sso/routes/schemas.rb +77 -0
- data/lib/better_auth/sso/routes/sso.rb +43 -0
- data/lib/better_auth/sso/saml/algorithms.rb +96 -0
- data/lib/better_auth/sso/saml/assertions.rb +21 -0
- data/lib/better_auth/sso/saml/error_codes.rb +24 -0
- data/lib/better_auth/sso/saml/parser.rb +19 -0
- data/lib/better_auth/sso/saml/timestamp.rb +19 -0
- data/lib/better_auth/sso/saml.rb +24 -4
- data/lib/better_auth/sso/saml_state.rb +30 -0
- data/lib/better_auth/sso/types.rb +20 -0
- data/lib/better_auth/sso/utils.rb +55 -0
- data/lib/better_auth/sso/version.rb +2 -1
- data/lib/better_auth/sso.rb +24 -0
- metadata +45 -1
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module SSO
|
|
5
|
+
module Constants
|
|
6
|
+
AUTHN_REQUEST_KEY_PREFIX = BetterAuth::Plugins::SSO_SAML_AUTHN_REQUEST_KEY_PREFIX
|
|
7
|
+
USED_ASSERTION_KEY_PREFIX = BetterAuth::Plugins::SSO_SAML_USED_ASSERTION_KEY_PREFIX
|
|
8
|
+
SAML_SESSION_KEY_PREFIX = BetterAuth::Plugins::SSO_SAML_SESSION_KEY_PREFIX
|
|
9
|
+
SAML_SESSION_BY_ID_PREFIX = BetterAuth::Plugins::SSO_SAML_SESSION_BY_ID_KEY_PREFIX
|
|
10
|
+
LOGOUT_REQUEST_KEY_PREFIX = BetterAuth::Plugins::SSO_SAML_LOGOUT_REQUEST_KEY_PREFIX
|
|
11
|
+
DEFAULT_AUTHN_REQUEST_TTL_MS = BetterAuth::Plugins::SSO_DEFAULT_AUTHN_REQUEST_TTL_MS
|
|
12
|
+
DEFAULT_ASSERTION_TTL_MS = BetterAuth::Plugins::SSO_DEFAULT_ASSERTION_TTL_MS
|
|
13
|
+
DEFAULT_LOGOUT_REQUEST_TTL_MS = BetterAuth::Plugins::SSO_DEFAULT_LOGOUT_REQUEST_TTL_MS
|
|
14
|
+
DEFAULT_CLOCK_SKEW_MS = BetterAuth::Plugins::SSO_DEFAULT_CLOCK_SKEW_MS
|
|
15
|
+
DEFAULT_MAX_SAML_RESPONSE_SIZE = BetterAuth::Plugins::SSO_DEFAULT_MAX_SAML_RESPONSE_SIZE
|
|
16
|
+
DEFAULT_MAX_SAML_METADATA_SIZE = BetterAuth::Plugins::SSO_DEFAULT_MAX_SAML_METADATA_SIZE
|
|
17
|
+
SAML_STATUS_SUCCESS = BetterAuth::Plugins::SSO_SAML_STATUS_SUCCESS
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module SSO
|
|
5
|
+
module DomainVerification
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def identifier(config, provider_id)
|
|
9
|
+
BetterAuth::Plugins.sso_domain_verification_identifier(config, provider_id)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def hostname(domain)
|
|
13
|
+
BetterAuth::Plugins.sso_hostname_from_domain(domain)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module SSO
|
|
5
|
+
module Linking
|
|
6
|
+
module OrgAssignment
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def assign_organization_from_provider(ctx, provider:, user:, profile: {}, token: nil, provisioning_options: nil, config: {})
|
|
10
|
+
organization_id = fetch_value(provider, :organization_id)
|
|
11
|
+
return if organization_id.to_s.empty?
|
|
12
|
+
|
|
13
|
+
options = normalized_provisioning_options(provisioning_options, config)
|
|
14
|
+
return if options[:disabled]
|
|
15
|
+
return unless organization_plugin?(ctx)
|
|
16
|
+
return if member_exists?(ctx, organization_id, fetch_value(user, :id))
|
|
17
|
+
|
|
18
|
+
role = organization_role(
|
|
19
|
+
options,
|
|
20
|
+
user: user,
|
|
21
|
+
user_info: fetch_value(profile || {}, :raw_attributes) || {},
|
|
22
|
+
token: token,
|
|
23
|
+
provider: provider
|
|
24
|
+
)
|
|
25
|
+
create_member(ctx, organization_id, fetch_value(user, :id), role)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def assign_organization_by_domain(ctx, user:, provisioning_options: nil, domain_verification: nil, config: {})
|
|
29
|
+
options = normalized_provisioning_options(provisioning_options, config)
|
|
30
|
+
return if options[:disabled]
|
|
31
|
+
return unless organization_plugin?(ctx)
|
|
32
|
+
|
|
33
|
+
domain_config = BetterAuth::Plugins.normalize_hash(domain_verification || config[:domain_verification] || {})
|
|
34
|
+
domain = fetch_value(user, :email).to_s.split("@", 2)[1]
|
|
35
|
+
return if domain.to_s.empty?
|
|
36
|
+
|
|
37
|
+
where = [{field: "domain", value: domain}]
|
|
38
|
+
where << {field: "domainVerified", value: true} if domain_config[:enabled]
|
|
39
|
+
provider = ctx.context.adapter.find_one(model: "ssoProvider", where: where)
|
|
40
|
+
|
|
41
|
+
unless provider
|
|
42
|
+
fallback_where = domain_config[:enabled] ? [{field: "domainVerified", value: true}] : []
|
|
43
|
+
providers = ctx.context.adapter.find_many(model: "ssoProvider", where: fallback_where)
|
|
44
|
+
provider = providers.find do |entry|
|
|
45
|
+
(!domain_config[:enabled] || fetch_value(entry, :domain_verified)) &&
|
|
46
|
+
BetterAuth::SSO::Utils.domain_matches?(domain, fetch_value(entry, :domain))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
organization_id = fetch_value(provider || {}, :organization_id)
|
|
51
|
+
return if organization_id.to_s.empty?
|
|
52
|
+
return if member_exists?(ctx, organization_id, fetch_value(user, :id))
|
|
53
|
+
|
|
54
|
+
role = organization_role(
|
|
55
|
+
options,
|
|
56
|
+
user: user,
|
|
57
|
+
user_info: {},
|
|
58
|
+
provider: provider
|
|
59
|
+
)
|
|
60
|
+
create_member(ctx, organization_id, fetch_value(user, :id), role)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def normalized_provisioning_options(provisioning_options, config)
|
|
64
|
+
BetterAuth::Plugins.normalize_hash(provisioning_options || config[:organization_provisioning] || {})
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def organization_plugin?(ctx)
|
|
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
|
+
plugins = context.options.respond_to?(:plugins) ? context.options.plugins : []
|
|
73
|
+
plugins.any? { |plugin| plugin.respond_to?(:id) && plugin.id == "organization" }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def member_exists?(ctx, organization_id, user_id)
|
|
77
|
+
ctx.context.adapter.find_one(
|
|
78
|
+
model: "member",
|
|
79
|
+
where: [
|
|
80
|
+
{field: "organizationId", value: organization_id},
|
|
81
|
+
{field: "userId", value: user_id}
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def organization_role(options, user:, user_info:, provider:, token: nil)
|
|
87
|
+
get_role = options[:get_role]
|
|
88
|
+
if get_role.respond_to?(:call)
|
|
89
|
+
return get_role.call(
|
|
90
|
+
user: user,
|
|
91
|
+
userInfo: user_info,
|
|
92
|
+
token: token,
|
|
93
|
+
provider: provider
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
options[:default_role] || options[:role] || "member"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def create_member(ctx, organization_id, user_id, role)
|
|
101
|
+
ctx.context.adapter.create(
|
|
102
|
+
model: "member",
|
|
103
|
+
data: {
|
|
104
|
+
organizationId: organization_id,
|
|
105
|
+
userId: user_id,
|
|
106
|
+
role: role,
|
|
107
|
+
createdAt: Time.now
|
|
108
|
+
}
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def fetch_value(data, key)
|
|
113
|
+
BetterAuth::Plugins.sso_fetch(data, key)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module SSO
|
|
5
|
+
module Linking
|
|
6
|
+
module Types
|
|
7
|
+
REQUIRED_PROFILE_KEYS = {
|
|
8
|
+
provider_type: "providerType",
|
|
9
|
+
provider_id: "providerId",
|
|
10
|
+
account_id: "accountId",
|
|
11
|
+
email: "email",
|
|
12
|
+
email_verified: "emailVerified"
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def normalized_profile(profile)
|
|
18
|
+
raw_attributes = raw_value(profile, :raw_attributes, "rawAttributes", "raw_attributes")
|
|
19
|
+
source = BetterAuth::Plugins.normalize_hash(profile || {})
|
|
20
|
+
normalized = {
|
|
21
|
+
provider_type: source[:provider_type].to_s,
|
|
22
|
+
provider_id: source[:provider_id].to_s,
|
|
23
|
+
account_id: source[:account_id].to_s,
|
|
24
|
+
email: source[:email].to_s.downcase,
|
|
25
|
+
email_verified: !!source[:email_verified]
|
|
26
|
+
}
|
|
27
|
+
normalized[:name] = source[:name] if source.key?(:name)
|
|
28
|
+
normalized[:image] = source[:image] if source.key?(:image)
|
|
29
|
+
normalized[:raw_attributes] = raw_attributes unless raw_attributes.nil?
|
|
30
|
+
|
|
31
|
+
missing = REQUIRED_PROFILE_KEYS.filter_map do |key, upstream_name|
|
|
32
|
+
value = normalized[key]
|
|
33
|
+
upstream_name if value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
34
|
+
end
|
|
35
|
+
raise ArgumentError, "Missing normalized SSO profile fields: #{missing.join(", ")}" unless missing.empty?
|
|
36
|
+
raise ArgumentError, "Invalid normalized SSO profile providerType: #{normalized[:provider_type]}" unless BetterAuth::SSO::Types.provider_type?(normalized[:provider_type])
|
|
37
|
+
|
|
38
|
+
normalized.freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def raw_value(profile, *keys)
|
|
42
|
+
return nil unless profile.respond_to?(:key?) && profile.respond_to?(:[])
|
|
43
|
+
|
|
44
|
+
keys.each do |key|
|
|
45
|
+
return profile[key] if profile.key?(key)
|
|
46
|
+
end
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "linking/types"
|
|
4
|
+
require_relative "linking/org_assignment"
|
|
5
|
+
|
|
6
|
+
module BetterAuth
|
|
7
|
+
module SSO
|
|
8
|
+
module Linking
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def assign_organization_from_provider(ctx, provider:, user:, config: {})
|
|
12
|
+
OrgAssignment.assign_organization_from_provider(ctx, provider: provider, user: user, config: config)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def assign_organization_by_domain(ctx, user:, config: {})
|
|
16
|
+
OrgAssignment.assign_organization_by_domain(ctx, user: user, config: config)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def normalized_profile(profile)
|
|
20
|
+
Types.normalized_profile(profile)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "json"
|
|
5
|
+
require "net/http"
|
|
6
|
+
|
|
7
|
+
module BetterAuth
|
|
8
|
+
module SSO
|
|
9
|
+
module OIDC
|
|
10
|
+
module Discovery
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
REQUIRED_DISCOVERY_FIELDS = %i[issuer authorization_endpoint token_endpoint jwks_uri].freeze
|
|
14
|
+
DISCOVERY_URL_FIELDS = %i[
|
|
15
|
+
token_endpoint
|
|
16
|
+
authorization_endpoint
|
|
17
|
+
jwks_uri
|
|
18
|
+
userinfo_endpoint
|
|
19
|
+
revocation_endpoint
|
|
20
|
+
end_session_endpoint
|
|
21
|
+
introspection_endpoint
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def compute_discovery_url(issuer)
|
|
25
|
+
"#{issuer.to_s.sub(%r{/+\z}, "")}/.well-known/openid-configuration"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def validate_discovery_url(url, trusted_origin = nil)
|
|
29
|
+
uri = parse_http_url!(url, "discoveryEndpoint", details: {url: url})
|
|
30
|
+
return true unless trusted_origin && !trusted_origin.call(uri.to_s)
|
|
31
|
+
|
|
32
|
+
raise DiscoveryError.new(
|
|
33
|
+
"discovery_untrusted_origin",
|
|
34
|
+
"The main discovery endpoint \"#{uri}\" is not trusted by your trusted origins configuration.",
|
|
35
|
+
details: {url: uri.to_s}
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate_discovery_document(document, issuer)
|
|
40
|
+
doc = BetterAuth::Plugins.normalize_hash(document || {})
|
|
41
|
+
missing = REQUIRED_DISCOVERY_FIELDS.select { |field| doc[field].to_s.empty? }
|
|
42
|
+
unless missing.empty?
|
|
43
|
+
raise DiscoveryError.new(
|
|
44
|
+
"discovery_incomplete",
|
|
45
|
+
"OIDC discovery document is missing required fields: #{missing.join(", ")}",
|
|
46
|
+
details: {missingFields: missing.map(&:to_s)}
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
discovered = doc[:issuer].to_s.sub(%r{/+\z}, "")
|
|
51
|
+
configured = issuer.to_s.sub(%r{/+\z}, "")
|
|
52
|
+
return true if discovered == configured
|
|
53
|
+
|
|
54
|
+
raise DiscoveryError.new(
|
|
55
|
+
"issuer_mismatch",
|
|
56
|
+
"OIDC discovery issuer does not match configured issuer",
|
|
57
|
+
details: {discovered: doc[:issuer], configured: issuer}
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def normalize_discovery_urls(document, issuer, trusted_origin = nil)
|
|
62
|
+
doc = BetterAuth::Plugins.normalize_hash(document || {}).dup
|
|
63
|
+
DISCOVERY_URL_FIELDS.each do |field|
|
|
64
|
+
next if doc[field].to_s.empty?
|
|
65
|
+
|
|
66
|
+
doc[field] = normalize_url(field.to_s, doc[field], issuer, trusted_origin)
|
|
67
|
+
end
|
|
68
|
+
doc
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def fetch_discovery_document(url, timeout: nil, fetch: nil)
|
|
72
|
+
response = if fetch
|
|
73
|
+
fetch.call(url, timeout: timeout)
|
|
74
|
+
else
|
|
75
|
+
uri = URI(url)
|
|
76
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: timeout) do |http|
|
|
77
|
+
http.get(uri.request_uri)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
parse_discovery_fetch_response(response)
|
|
81
|
+
rescue DiscoveryError
|
|
82
|
+
raise
|
|
83
|
+
rescue Timeout::Error
|
|
84
|
+
raise DiscoveryError.new("discovery_timeout", "OIDC discovery request timed out", details: {url: url})
|
|
85
|
+
rescue => exception
|
|
86
|
+
if exception.message.match?(/aborted/i)
|
|
87
|
+
raise DiscoveryError.new("discovery_timeout", "OIDC discovery request timed out", details: {url: url})
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
raise DiscoveryError.new("discovery_unexpected_error", "OIDC discovery request failed", details: {url: url, error: exception.message})
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def discover_oidc_config(issuer:, fetch: nil, existing_config: nil, discovery_endpoint: nil, trusted_origin: nil, is_trusted_origin: nil, timeout: nil)
|
|
94
|
+
existing = BetterAuth::Plugins.normalize_hash(existing_config || {})
|
|
95
|
+
origin_check = trusted_origin || is_trusted_origin
|
|
96
|
+
discovery_url = discovery_endpoint || existing[:discovery_endpoint] || compute_discovery_url(issuer)
|
|
97
|
+
validate_discovery_url(discovery_url, origin_check)
|
|
98
|
+
|
|
99
|
+
document = fetch_discovery_document(discovery_url, timeout: timeout, fetch: fetch)
|
|
100
|
+
validate_discovery_document(document, issuer)
|
|
101
|
+
normalized_document = normalize_discovery_urls(document, issuer, origin_check)
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
issuer: existing[:issuer] || normalized_document[:issuer],
|
|
105
|
+
discovery_endpoint: existing[:discovery_endpoint] || discovery_url,
|
|
106
|
+
client_id: existing[:client_id],
|
|
107
|
+
client_secret: existing[:client_secret],
|
|
108
|
+
authorization_endpoint: existing[:authorization_endpoint] || normalized_document[:authorization_endpoint],
|
|
109
|
+
token_endpoint: existing[:token_endpoint] || normalized_document[:token_endpoint],
|
|
110
|
+
jwks_endpoint: existing[:jwks_endpoint] || normalized_document[:jwks_uri],
|
|
111
|
+
user_info_endpoint: existing[:user_info_endpoint] || normalized_document[:userinfo_endpoint],
|
|
112
|
+
token_endpoint_authentication: select_token_endpoint_auth_method(normalized_document, existing[:token_endpoint_authentication]),
|
|
113
|
+
scopes_supported: existing[:scopes_supported] || normalized_document[:scopes_supported],
|
|
114
|
+
pkce: existing[:pkce],
|
|
115
|
+
override_user_info: existing[:override_user_info],
|
|
116
|
+
mapping: existing[:mapping]
|
|
117
|
+
}.compact
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def normalize_url(name_or_value, value_or_issuer, issuer = nil, trusted_origin = nil)
|
|
121
|
+
name = issuer.nil? ? "url" : name_or_value.to_s
|
|
122
|
+
value = issuer.nil? ? name_or_value : value_or_issuer
|
|
123
|
+
issuer_value = issuer.nil? ? value_or_issuer : issuer
|
|
124
|
+
normalized = normalize_endpoint_url(name, value, issuer_value)
|
|
125
|
+
|
|
126
|
+
if trusted_origin && !trusted_origin.call(normalized)
|
|
127
|
+
raise DiscoveryError.new(
|
|
128
|
+
"discovery_untrusted_origin",
|
|
129
|
+
"The #{name} \"#{normalized}\" is not trusted by your trusted origins configuration.",
|
|
130
|
+
details: {endpoint: name, url: normalized}
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
normalized
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def needs_runtime_discovery?(oidc_config)
|
|
138
|
+
config = BetterAuth::Plugins.normalize_hash(oidc_config || {})
|
|
139
|
+
config[:authorization_endpoint].to_s.empty? ||
|
|
140
|
+
config[:token_endpoint].to_s.empty? ||
|
|
141
|
+
config[:jwks_endpoint].to_s.empty?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def ensure_runtime_discovery(config, issuer, trusted_origin, fetch: nil, timeout: nil)
|
|
145
|
+
normalized = BetterAuth::Plugins.normalize_hash(config || {})
|
|
146
|
+
return config unless needs_runtime_discovery?(normalized)
|
|
147
|
+
|
|
148
|
+
discovered = discover_oidc_config(
|
|
149
|
+
issuer: issuer,
|
|
150
|
+
existing_config: normalized,
|
|
151
|
+
trusted_origin: trusted_origin,
|
|
152
|
+
fetch: fetch,
|
|
153
|
+
timeout: timeout
|
|
154
|
+
)
|
|
155
|
+
normalized.merge(
|
|
156
|
+
authorization_endpoint: discovered[:authorization_endpoint],
|
|
157
|
+
token_endpoint: discovered[:token_endpoint],
|
|
158
|
+
token_endpoint_authentication: discovered[:token_endpoint_authentication],
|
|
159
|
+
user_info_endpoint: discovered[:user_info_endpoint],
|
|
160
|
+
jwks_endpoint: discovered[:jwks_endpoint]
|
|
161
|
+
).compact
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def select_token_endpoint_auth_method(document_or_config = {}, existing_method = nil)
|
|
165
|
+
return existing_method if existing_method
|
|
166
|
+
|
|
167
|
+
config = BetterAuth::Plugins.normalize_hash(document_or_config || {})
|
|
168
|
+
return config[:token_endpoint_authentication] if config[:token_endpoint_authentication]
|
|
169
|
+
|
|
170
|
+
methods = config[:token_endpoint_auth_methods_supported] || config[:methods] || []
|
|
171
|
+
return "client_secret_post" if Array(methods).include?("client_secret_post") && !Array(methods).include?("client_secret_basic")
|
|
172
|
+
|
|
173
|
+
"client_secret_basic"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def parse_http_url!(url, name, details: {})
|
|
177
|
+
uri = URI.parse(url.to_s)
|
|
178
|
+
raise URI::InvalidURIError if uri.scheme.to_s.empty? || uri.host.to_s.empty?
|
|
179
|
+
unless %w[http https].include?(uri.scheme)
|
|
180
|
+
raise DiscoveryError.new(
|
|
181
|
+
"discovery_invalid_url",
|
|
182
|
+
"The url \"#{name}\" must use the http or https supported protocols",
|
|
183
|
+
details: details.merge(protocol: "#{uri.scheme}:")
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
uri
|
|
188
|
+
rescue URI::InvalidURIError
|
|
189
|
+
raise DiscoveryError.new(
|
|
190
|
+
"discovery_invalid_url",
|
|
191
|
+
"The url \"#{name}\" must be valid",
|
|
192
|
+
details: details
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def normalize_endpoint_url(name, endpoint, issuer)
|
|
197
|
+
raw = endpoint.to_s
|
|
198
|
+
if raw.match?(%r{\Ahttps?://}i)
|
|
199
|
+
uri = parse_http_url!(raw, name, details: {endpoint: name, url: raw})
|
|
200
|
+
return uri.to_s
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
issuer_uri = parse_http_url!(issuer, name, details: {endpoint: name, url: raw})
|
|
204
|
+
issuer_base = issuer_uri.to_s.sub(%r{/+\z}, "")
|
|
205
|
+
endpoint_path = raw.sub(%r{\A/+}, "")
|
|
206
|
+
normalized = "#{issuer_base}/#{endpoint_path}"
|
|
207
|
+
parse_http_url!(normalized, name, details: {endpoint: name, url: normalized}).to_s
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def parse_discovery_fetch_response(response)
|
|
211
|
+
if response.respond_to?(:code) && response.respond_to?(:body)
|
|
212
|
+
status = response.code.to_i
|
|
213
|
+
body = response.body
|
|
214
|
+
return parse_discovery_body(body) if status.between?(200, 299)
|
|
215
|
+
|
|
216
|
+
raise_discovery_http_error(status, response.message.to_s)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
normalized = response.is_a?(Hash) ? BetterAuth::Plugins.normalize_hash(response) : {data: response}
|
|
220
|
+
error = normalized[:error]
|
|
221
|
+
if error
|
|
222
|
+
error_hash = BetterAuth::Plugins.normalize_hash(error)
|
|
223
|
+
raise_discovery_http_error(error_hash[:status].to_i, error_hash[:message].to_s)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
data = normalized.key?(:data) ? normalized[:data] : normalized
|
|
227
|
+
parse_discovery_body(data)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def parse_discovery_body(data)
|
|
231
|
+
raise DiscoveryError.new("discovery_invalid_json", "OIDC discovery response was empty") if data.nil?
|
|
232
|
+
return BetterAuth::Plugins.normalize_hash(data) if data.is_a?(Hash)
|
|
233
|
+
|
|
234
|
+
parsed = JSON.parse(data.to_s)
|
|
235
|
+
raise JSON::ParserError if !parsed.is_a?(Hash)
|
|
236
|
+
|
|
237
|
+
BetterAuth::Plugins.normalize_hash(parsed)
|
|
238
|
+
rescue JSON::ParserError
|
|
239
|
+
raise DiscoveryError.new(
|
|
240
|
+
"discovery_invalid_json",
|
|
241
|
+
"OIDC discovery response was not valid JSON",
|
|
242
|
+
details: {bodyPreview: data.to_s[0, 200]}
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def raise_discovery_http_error(status, message)
|
|
247
|
+
case status
|
|
248
|
+
when 404
|
|
249
|
+
raise DiscoveryError.new("discovery_not_found", "OIDC discovery endpoint was not found", details: {status: status, message: message})
|
|
250
|
+
when 408
|
|
251
|
+
raise DiscoveryError.new("discovery_timeout", "OIDC discovery request timed out", details: {status: status, message: message})
|
|
252
|
+
else
|
|
253
|
+
raise DiscoveryError.new("discovery_unexpected_error", "OIDC discovery request failed", details: {status: status, message: message})
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module SSO
|
|
5
|
+
module OIDC
|
|
6
|
+
class DiscoveryError < StandardError
|
|
7
|
+
attr_reader :code, :details
|
|
8
|
+
|
|
9
|
+
def initialize(code, message, details: {})
|
|
10
|
+
@code = code
|
|
11
|
+
@details = details
|
|
12
|
+
super(message)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module Errors
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def api_error(error)
|
|
20
|
+
return error if error.is_a?(APIError)
|
|
21
|
+
|
|
22
|
+
APIError.new("BAD_REQUEST", message: error.message)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module SSO
|
|
5
|
+
module OIDC
|
|
6
|
+
module Types
|
|
7
|
+
DISCOVERY_ERROR_CODES = %w[
|
|
8
|
+
discovery_timeout
|
|
9
|
+
discovery_not_found
|
|
10
|
+
discovery_invalid_json
|
|
11
|
+
discovery_invalid_url
|
|
12
|
+
discovery_untrusted_origin
|
|
13
|
+
issuer_mismatch
|
|
14
|
+
discovery_incomplete
|
|
15
|
+
unsupported_token_auth_method
|
|
16
|
+
discovery_unexpected_error
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
REQUIRED_DISCOVERY_FIELDS = Discovery::REQUIRED_DISCOVERY_FIELDS
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def discovery_error_code?(value)
|
|
24
|
+
DISCOVERY_ERROR_CODES.include?(value.to_s)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "oidc/discovery"
|
|
4
|
+
require_relative "oidc/errors"
|
|
5
|
+
|
|
6
|
+
module BetterAuth
|
|
7
|
+
module SSO
|
|
8
|
+
module OIDC
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def discover_config(**kwargs)
|
|
12
|
+
Discovery.discover_oidc_config(**kwargs)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def needs_runtime_discovery?(oidc_config)
|
|
16
|
+
Discovery.needs_runtime_discovery?(oidc_config)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module SSO
|
|
5
|
+
module Routes
|
|
6
|
+
module DomainVerification
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def identifier(config, provider_id)
|
|
10
|
+
BetterAuth::Plugins.sso_domain_verification_identifier(config, provider_id)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def hostname(domain)
|
|
14
|
+
BetterAuth::Plugins.sso_hostname_from_domain(domain)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module SSO
|
|
5
|
+
module Routes
|
|
6
|
+
module Helpers
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def find_saml_provider!(ctx, provider_id)
|
|
10
|
+
BetterAuth::Plugins.sso_find_provider!(ctx, provider_id)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def create_saml_post_form(action, saml_param, saml_value, relay_state = nil)
|
|
14
|
+
BetterAuth::Plugins.sso_saml_post_form(action, saml_param, saml_value, relay_state)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module SSO
|
|
5
|
+
module Routes
|
|
6
|
+
module Providers
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def sanitize(provider, context)
|
|
10
|
+
BetterAuth::Plugins.sso_sanitize_provider(provider, context)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def provider_access?(provider, user_id, ctx)
|
|
14
|
+
BetterAuth::Plugins.sso_provider_access?(provider, user_id, ctx)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module SSO
|
|
5
|
+
module Routes
|
|
6
|
+
module SAMLPipeline
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def process_response(ctx, config = {})
|
|
10
|
+
BetterAuth::Plugins.sso_handle_saml_response(ctx, config)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def safe_redirect_url(ctx, url, provider_id)
|
|
14
|
+
BetterAuth::Plugins.sso_safe_slo_redirect_url(ctx, url, provider_id)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|