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