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