rodauth-oauth 1.3.2 → 1.4.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/README.md +17 -10
- data/doc/release_notes/1_4_0.md +49 -0
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/authorize.html.erb +23 -23
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/frontchannel_logout.html.erb +10 -0
- data/lib/generators/rodauth/oauth/templates/app/views/rodauth/oauth_applications.html.erb +1 -1
- data/lib/generators/rodauth/oauth/templates/db/migrate/create_rodauth_oauth.rb +20 -0
- data/lib/generators/rodauth/oauth/views_generator.rb +2 -2
- data/lib/rodauth/features/oauth_application_management.rb +1 -1
- data/lib/rodauth/features/oauth_assertion_base.rb +1 -1
- data/lib/rodauth/features/oauth_authorize_base.rb +1 -1
- data/lib/rodauth/features/oauth_base.rb +31 -30
- data/lib/rodauth/features/oauth_device_code_grant.rb +2 -2
- data/lib/rodauth/features/oauth_dynamic_client_registration.rb +1 -0
- data/lib/rodauth/features/oauth_grant_management.rb +1 -1
- data/lib/rodauth/features/oauth_jwt.rb +3 -3
- data/lib/rodauth/features/oauth_jwt_base.rb +15 -10
- data/lib/rodauth/features/oauth_jwt_bearer_grant.rb +1 -1
- data/lib/rodauth/features/oauth_jwt_jwks.rb +1 -1
- data/lib/rodauth/features/oauth_resource_server.rb +1 -1
- data/lib/rodauth/features/oauth_saml_bearer_grant.rb +79 -47
- data/lib/rodauth/features/oauth_tls_client_auth.rb +1 -1
- data/lib/rodauth/features/oauth_token_introspection.rb +1 -1
- data/lib/rodauth/features/oauth_token_revocation.rb +1 -1
- data/lib/rodauth/features/oidc.rb +27 -8
- data/lib/rodauth/features/oidc_backchannel_logout.rb +120 -0
- data/lib/rodauth/features/oidc_dynamic_client_registration.rb +25 -0
- data/lib/rodauth/features/oidc_frontchannel_logout.rb +134 -0
- data/lib/rodauth/features/oidc_logout_base.rb +76 -0
- data/lib/rodauth/features/oidc_rp_initiated_logout.rb +29 -6
- data/lib/rodauth/features/oidc_session_management.rb +89 -0
- data/lib/rodauth/oauth/http_extensions.rb +1 -1
- data/lib/rodauth/oauth/version.rb +1 -1
- data/locales/en.yml +9 -0
- data/locales/pt.yml +9 -0
- data/templates/check_session.str +67 -0
- data/templates/frontchannel_logout.str +17 -0
- metadata +11 -2
@@ -7,19 +7,28 @@ module Rodauth
|
|
7
7
|
Feature.define(:oauth_saml_bearer_grant, :OauthSamlBearerGrant) do
|
8
8
|
depends :oauth_assertion_base
|
9
9
|
|
10
|
-
auth_value_method :oauth_saml_cert_fingerprint, "9E:65:2E:03:06:8D:80:F2:86:C7:6C:77:A1:D9:14:97:0A:4D:F4:4D"
|
11
|
-
auth_value_method :oauth_saml_cert, nil
|
12
|
-
auth_value_method :oauth_saml_cert_fingerprint_algorithm, nil
|
13
10
|
auth_value_method :oauth_saml_name_identifier_format, "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
|
14
|
-
|
15
|
-
auth_value_method :oauth_saml_security_authn_requests_signed, true
|
16
|
-
auth_value_method :oauth_saml_security_metadata_signed, true
|
17
|
-
auth_value_method :oauth_saml_security_digest_method, XMLSecurity::Document::SHA1
|
18
|
-
auth_value_method :oauth_saml_security_signature_method, XMLSecurity::Document::RSA_SHA1
|
11
|
+
auth_value_method :oauth_saml_idp_cert_check_expiration, true
|
19
12
|
|
20
13
|
auth_value_method :max_param_bytesize, nil if Rodauth::VERSION >= "2.26.0"
|
21
14
|
|
22
|
-
|
15
|
+
auth_value_method :oauth_saml_settings_table, :oauth_saml_settings
|
16
|
+
%i[
|
17
|
+
id oauth_application_id
|
18
|
+
idp_cert idp_cert_fingerprint idp_cert_fingerprint_algorithm
|
19
|
+
name_identifier_format
|
20
|
+
issuer
|
21
|
+
audience
|
22
|
+
idp_cert_check_expiration
|
23
|
+
].each do |column|
|
24
|
+
auth_value_method :"oauth_saml_settings_#{column}_column", column
|
25
|
+
end
|
26
|
+
|
27
|
+
translatable_method :oauth_saml_assertion_not_base64_message, "SAML assertion must be in base64 format"
|
28
|
+
translatable_method :oauth_saml_assertion_single_issuer_message, "SAML assertion must have a single issuer"
|
29
|
+
translatable_method :oauth_saml_settings_not_found_message, "No SAML settings found for issuer"
|
30
|
+
|
31
|
+
auth_methods(
|
23
32
|
:require_oauth_application_from_saml2_bearer_assertion_issuer,
|
24
33
|
:require_oauth_application_from_saml2_bearer_assertion_subject,
|
25
34
|
:account_from_saml2_bearer_assertion
|
@@ -32,72 +41,95 @@ module Rodauth
|
|
32
41
|
private
|
33
42
|
|
34
43
|
def require_oauth_application_from_saml2_bearer_assertion_issuer(assertion)
|
35
|
-
|
44
|
+
parse_saml_assertion(assertion)
|
36
45
|
|
37
|
-
return unless
|
46
|
+
return unless @saml_settings
|
38
47
|
|
39
48
|
db[oauth_applications_table].where(
|
40
|
-
|
49
|
+
oauth_applications_id_column => @saml_settings[oauth_saml_settings_oauth_application_id_column]
|
41
50
|
).first
|
42
51
|
end
|
43
52
|
|
44
53
|
def require_oauth_application_from_saml2_bearer_assertion_subject(assertion)
|
45
|
-
|
54
|
+
parse_saml_assertion(assertion)
|
46
55
|
|
47
|
-
return unless
|
56
|
+
return unless @assertion
|
48
57
|
|
58
|
+
# 3.3.8 - For client authentication, the Subject MUST be the "client_id" of the OAuth client.
|
49
59
|
db[oauth_applications_table].where(
|
50
|
-
oauth_applications_client_id_column =>
|
60
|
+
oauth_applications_client_id_column => @assertion.nameid
|
51
61
|
).first
|
52
62
|
end
|
53
63
|
|
54
64
|
def account_from_saml2_bearer_assertion(assertion)
|
55
|
-
|
65
|
+
parse_saml_assertion(assertion)
|
56
66
|
|
57
|
-
return unless
|
67
|
+
return unless @assertion
|
58
68
|
|
59
|
-
account_from_bearer_assertion_subject(
|
69
|
+
account_from_bearer_assertion_subject(@assertion.nameid)
|
60
70
|
end
|
61
71
|
|
62
|
-
def
|
72
|
+
def generate_saml_settings(saml_settings)
|
63
73
|
settings = OneLogin::RubySaml::Settings.new
|
64
|
-
settings.idp_cert = oauth_saml_cert
|
65
|
-
settings.idp_cert_fingerprint = oauth_saml_cert_fingerprint
|
66
|
-
settings.idp_cert_fingerprint_algorithm = oauth_saml_cert_fingerprint_algorithm
|
67
|
-
settings.name_identifier_format = oauth_saml_name_identifier_format
|
68
|
-
settings.security[:authn_requests_signed] = oauth_saml_security_authn_requests_signed
|
69
|
-
settings.security[:metadata_signed] = oauth_saml_security_metadata_signed
|
70
|
-
settings.security[:digest_method] = oauth_saml_security_digest_method
|
71
|
-
settings.security[:signature_method] = oauth_saml_security_signature_method
|
72
74
|
|
73
|
-
|
75
|
+
# issuer
|
76
|
+
settings.idp_entity_id = saml_settings[oauth_saml_settings_issuer_column]
|
74
77
|
|
75
|
-
#
|
76
|
-
|
77
|
-
|
78
|
-
#
|
79
|
-
|
80
|
-
|
78
|
+
# audience
|
79
|
+
settings.sp_entity_id = saml_settings[oauth_saml_settings_audience_column] || token_url
|
80
|
+
|
81
|
+
# recipient
|
82
|
+
settings.assertion_consumer_service_url = token_url
|
83
|
+
|
84
|
+
settings.idp_cert = saml_settings[oauth_saml_settings_idp_cert_column]
|
85
|
+
settings.idp_cert_fingerprint = saml_settings[oauth_saml_settings_idp_cert_fingerprint_column]
|
86
|
+
settings.idp_cert_fingerprint_algorithm = saml_settings[oauth_saml_settings_idp_cert_fingerprint_algorithm_column]
|
87
|
+
|
88
|
+
if settings.idp_cert
|
89
|
+
check_idp_cert_expiration = saml_settings[oauth_saml_settings_idp_cert_check_expiration_column]
|
90
|
+
check_idp_cert_expiration = oauth_saml_idp_cert_check_expiration if check_idp_cert_expiration.nil?
|
91
|
+
settings.security[:check_idp_cert_expiration] = check_idp_cert_expiration
|
92
|
+
end
|
93
|
+
settings.security[:strict_audience_validation] = true
|
94
|
+
settings.security[:want_name_id] = true
|
81
95
|
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
96
|
+
settings.name_identifier_format = saml_settings[oauth_saml_settings_name_identifier_format_column] ||
|
97
|
+
oauth_saml_name_identifier_format
|
98
|
+
settings
|
99
|
+
end
|
100
|
+
|
101
|
+
# rubocop:disable Naming/MemoizedInstanceVariableName
|
102
|
+
def parse_saml_assertion(assertion)
|
103
|
+
return @assertion if defined?(@assertion)
|
104
|
+
|
105
|
+
response = OneLogin::RubySaml::Response.new(assertion)
|
106
|
+
|
107
|
+
# The SAML Assertion XML data MUST be encoded using base64url
|
108
|
+
redirect_response_error("invalid_grant", oauth_saml_assertion_not_base64_message) unless response.send(:base64_encoded?, assertion)
|
86
109
|
|
87
110
|
# 1. The Assertion's <Issuer> element MUST contain a unique identifier
|
88
111
|
# for the entity that issued the Assertion.
|
89
|
-
redirect_response_error("invalid_grant") unless response.issuers.size == 1
|
112
|
+
redirect_response_error("invalid_grant", oauth_saml_assertion_single_issuer_message) unless response.issuers.size == 1
|
113
|
+
|
114
|
+
@saml_settings = db[oauth_saml_settings_table].where(
|
115
|
+
oauth_saml_settings_issuer_column => response.issuers.first
|
116
|
+
).first
|
117
|
+
|
118
|
+
redirect_response_error("invalid_grant", oauth_saml_settings_not_found_message) unless @saml_settings
|
90
119
|
|
91
|
-
|
92
|
-
|
93
|
-
#
|
94
|
-
#
|
95
|
-
#
|
96
|
-
#
|
97
|
-
|
120
|
+
response.settings = generate_saml_settings(@saml_settings)
|
121
|
+
|
122
|
+
# 2. The Assertion MUST contain a <Conditions> element ...
|
123
|
+
# 3. he Assertion MUST have an expiry that limits the time window ...
|
124
|
+
# 4. The Assertion MUST have an expiry that limits the time window ...
|
125
|
+
# 5. The <Subject> element MUST contain at least one ...
|
126
|
+
# 6. The authorization server MUST reject the entire Assertion if the ...
|
127
|
+
# 7. If the Assertion issuer directly authenticated the subject, ...
|
128
|
+
redirect_response_error("invalid_grant", response.errors.join("; ")) unless response.is_valid?
|
98
129
|
|
99
|
-
response
|
130
|
+
@assertion = response
|
100
131
|
end
|
132
|
+
# rubocop:enable Naming/MemoizedInstanceVariableName
|
101
133
|
|
102
134
|
def oauth_server_metadata_body(*)
|
103
135
|
super.tap do |data|
|
@@ -51,6 +51,11 @@ module Rodauth
|
|
51
51
|
require_request_uri_registration
|
52
52
|
op_policy_uri
|
53
53
|
op_tos_uri
|
54
|
+
check_session_iframe
|
55
|
+
frontchannel_logout_supported
|
56
|
+
frontchannel_logout_session_supported
|
57
|
+
backchannel_logout_supported
|
58
|
+
backchannel_logout_session_supported
|
54
59
|
].freeze
|
55
60
|
|
56
61
|
REQUIRED_METADATA_KEYS = %i[
|
@@ -63,7 +68,7 @@ module Rodauth
|
|
63
68
|
id_token_signing_alg_values_supported
|
64
69
|
].freeze
|
65
70
|
|
66
|
-
depends :
|
71
|
+
depends :active_sessions, :oauth_jwt, :oauth_jwt_jwks, :oauth_authorization_code_grant, :oauth_implicit_grant
|
67
72
|
|
68
73
|
auth_value_method :oauth_application_scopes, %w[openid]
|
69
74
|
|
@@ -95,7 +100,10 @@ module Rodauth
|
|
95
100
|
:request_object_signing_alg_values_supported,
|
96
101
|
:request_object_encryption_alg_values_supported,
|
97
102
|
:request_object_encryption_enc_values_supported,
|
98
|
-
:oauth_acr_values_supported
|
103
|
+
:oauth_acr_values_supported
|
104
|
+
)
|
105
|
+
|
106
|
+
auth_methods(
|
99
107
|
:get_oidc_account_last_login_at,
|
100
108
|
:oidc_authorize_on_prompt_none?,
|
101
109
|
:fill_with_account_claims,
|
@@ -222,7 +230,7 @@ module Rodauth
|
|
222
230
|
def current_oauth_account
|
223
231
|
subject_type = current_oauth_application[oauth_applications_subject_type_column] || oauth_jwt_subject_type
|
224
232
|
|
225
|
-
|
233
|
+
super unless subject_type == "pairwise"
|
226
234
|
end
|
227
235
|
|
228
236
|
private
|
@@ -341,10 +349,17 @@ module Rodauth
|
|
341
349
|
end
|
342
350
|
|
343
351
|
def get_oidc_account_last_login_at(account_id)
|
344
|
-
get_activity_timestamp(account_id, account_activity_last_activity_column)
|
352
|
+
return get_activity_timestamp(account_id, account_activity_last_activity_column) if features.include?(:account_expiration)
|
353
|
+
|
354
|
+
# active sessions based
|
355
|
+
ds = db[active_sessions_table].where(active_sessions_account_id_column => account_id)
|
356
|
+
|
357
|
+
ds = ds.order(Sequel.desc(active_sessions_created_at_column))
|
358
|
+
|
359
|
+
convert_timestamp(ds.get(active_sessions_created_at_column))
|
345
360
|
end
|
346
361
|
|
347
|
-
def jwt_subject(
|
362
|
+
def jwt_subject(account_unique_id, client_application = oauth_application)
|
348
363
|
subject_type = client_application[oauth_applications_subject_type_column] || oauth_jwt_subject_type
|
349
364
|
|
350
365
|
case subject_type
|
@@ -368,8 +383,7 @@ module Rodauth
|
|
368
383
|
|
369
384
|
identifier_uri = URI(identifier_uri).host
|
370
385
|
|
371
|
-
|
372
|
-
values = [identifier_uri, *account_ids, oauth_jwt_subject_secret]
|
386
|
+
values = [identifier_uri, account_unique_id, oauth_jwt_subject_secret]
|
373
387
|
Digest::SHA256.hexdigest(values.join)
|
374
388
|
else
|
375
389
|
raise StandardError, "unexpected subject (#{subject_type})"
|
@@ -516,7 +530,12 @@ module Rodauth
|
|
516
530
|
end
|
517
531
|
end
|
518
532
|
|
519
|
-
#
|
533
|
+
# OpenID Connect Core 1.0's 5.4 Requesting Claims using Scope Values:
|
534
|
+
# If standard claims (profile, email, etc) are requested as scope values in the Authorization Request,
|
535
|
+
# include in the response.
|
536
|
+
include_claims ||= (OIDC_SCOPES_MAP.keys & oauth_scopes).any?
|
537
|
+
|
538
|
+
# However, when no Access Token is issued (which is the case for the response_type value id_token),
|
520
539
|
# the resulting Claims are returned in the ID Token.
|
521
540
|
fill_with_account_claims(id_claims, account, oauth_scopes, param_or_nil("claims_locales")) if include_claims
|
522
541
|
|
@@ -0,0 +1,120 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rodauth/oauth"
|
4
|
+
|
5
|
+
module Rodauth
|
6
|
+
Feature.define(:oidc_backchannel_logout, :OidBackchannelLogout) do
|
7
|
+
depends :logout, :oidc_logout_base
|
8
|
+
|
9
|
+
auth_value_method :oauth_logout_token_expires_in, 60 # 1 minute
|
10
|
+
auth_value_method :backchannel_logout_session_supported, true
|
11
|
+
auth_value_method :oauth_applications_backchannel_logout_uri_column, :backchannel_logout_uri
|
12
|
+
auth_value_method :oauth_applications_backchannel_logout_session_required_column, :backchannel_logout_session_required
|
13
|
+
|
14
|
+
auth_methods(
|
15
|
+
:perform_logout_requests
|
16
|
+
)
|
17
|
+
|
18
|
+
def logout
|
19
|
+
visited_sites = session[visited_sites_key]
|
20
|
+
|
21
|
+
return super unless visited_sites
|
22
|
+
|
23
|
+
oauth_applications = db[oauth_applications_table].where(oauth_applications_client_id_column => visited_sites.map(&:first))
|
24
|
+
.as_hash(oauth_applications_id_column)
|
25
|
+
|
26
|
+
logout_params = oauth_applications.flat_map do |_id, oauth_application|
|
27
|
+
logout_url = oauth_application[oauth_applications_backchannel_logout_uri_column]
|
28
|
+
|
29
|
+
next unless logout_url
|
30
|
+
|
31
|
+
client_id = oauth_application[oauth_applications_client_id_column]
|
32
|
+
|
33
|
+
sids = visited_sites.select { |cid, _| cid == client_id }.map(&:last)
|
34
|
+
|
35
|
+
sids.map do |sid|
|
36
|
+
logout_token = generate_logout_token(oauth_application, sid)
|
37
|
+
|
38
|
+
[logout_url, logout_token]
|
39
|
+
end
|
40
|
+
end.compact
|
41
|
+
|
42
|
+
perform_logout_requests(logout_params) unless logout_params.empty?
|
43
|
+
|
44
|
+
# now we can clear the session
|
45
|
+
super
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
def generate_logout_token(oauth_application, sid)
|
51
|
+
issued_at = Time.now.to_i
|
52
|
+
|
53
|
+
logout_claims = {
|
54
|
+
iss: oauth_jwt_issuer, # issuer
|
55
|
+
iat: issued_at, # issued at
|
56
|
+
exp: issued_at + oauth_logout_token_expires_in,
|
57
|
+
aud: oauth_application[oauth_applications_client_id_column],
|
58
|
+
events: {
|
59
|
+
"http://schemas.openid.net/event/backchannel-logout": {}
|
60
|
+
}
|
61
|
+
}
|
62
|
+
|
63
|
+
logout_claims[:sid] = sid if sid
|
64
|
+
|
65
|
+
signing_algorithm = oauth_application[oauth_applications_id_token_signed_response_alg_column] ||
|
66
|
+
oauth_jwt_keys.keys.first
|
67
|
+
|
68
|
+
params = {
|
69
|
+
jwks: oauth_application_jwks(oauth_application),
|
70
|
+
headers: { typ: "logout+jwt" },
|
71
|
+
signing_algorithm: signing_algorithm,
|
72
|
+
encryption_algorithm: oauth_application[oauth_applications_id_token_encrypted_response_alg_column],
|
73
|
+
encryption_method: oauth_application[oauth_applications_id_token_encrypted_response_enc_column]
|
74
|
+
}.compact
|
75
|
+
|
76
|
+
jwt_encode(logout_claims, **params)
|
77
|
+
end
|
78
|
+
|
79
|
+
def perform_logout_requests(logout_params)
|
80
|
+
# performs logout requests sequentially
|
81
|
+
logout_params.each do |logout_url, logout_token|
|
82
|
+
http_request(logout_url, { "logout_token" => logout_token })
|
83
|
+
rescue StandardError
|
84
|
+
warn "failed to perform backchannel logout on #{logout_url}"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def id_token_claims(oauth_grant, signing_algorithm)
|
89
|
+
claims = super
|
90
|
+
|
91
|
+
return claims unless oauth_application[oauth_applications_backchannel_logout_uri_column]
|
92
|
+
|
93
|
+
session_id_in_claims(oauth_grant, claims)
|
94
|
+
|
95
|
+
claims
|
96
|
+
end
|
97
|
+
|
98
|
+
def should_set_oauth_application_in_visited_sites?
|
99
|
+
true
|
100
|
+
end
|
101
|
+
|
102
|
+
def should_set_sid_in_visited_sites?(oauth_application)
|
103
|
+
super || requires_backchannel_logout_session?(oauth_application)
|
104
|
+
end
|
105
|
+
|
106
|
+
def requires_backchannel_logout_session?(oauth_application)
|
107
|
+
(
|
108
|
+
oauth_application &&
|
109
|
+
oauth_application[oauth_applications_backchannel_logout_session_required_column]
|
110
|
+
) || backchannel_logout_session_supported
|
111
|
+
end
|
112
|
+
|
113
|
+
def oauth_server_metadata_body(*)
|
114
|
+
super.tap do |data|
|
115
|
+
data[:backchannel_logout_supported] = true
|
116
|
+
data[:backchannel_logout_session_supported] = backchannel_logout_session_supported
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -145,6 +145,31 @@ module Rodauth
|
|
145
145
|
end
|
146
146
|
end
|
147
147
|
|
148
|
+
if features.include?(:oidc_frontchannel_logout)
|
149
|
+
if (value = @oauth_application_params[oauth_applications_frontchannel_logout_uri_column]) && !check_valid_no_fragment_uri?(value)
|
150
|
+
register_throw_json_response_error("invalid_client_metadata",
|
151
|
+
register_invalid_uri_message(value))
|
152
|
+
end
|
153
|
+
|
154
|
+
if (value = @oauth_application_params[oauth_applications_frontchannel_logout_session_required_column])
|
155
|
+
@oauth_application_params[oauth_applications_frontchannel_logout_session_required_column] =
|
156
|
+
convert_to_boolean("frontchannel_logout_session_required", value)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
if features.include?(:oidc_backchannel_logout)
|
161
|
+
if (value = @oauth_application_params[oauth_applications_backchannel_logout_uri_column]) && !check_valid_no_fragment_uri?(value)
|
162
|
+
register_throw_json_response_error("invalid_client_metadata",
|
163
|
+
register_invalid_uri_message(value))
|
164
|
+
end
|
165
|
+
|
166
|
+
if @oauth_application_params.key?(oauth_applications_backchannel_logout_session_required_column)
|
167
|
+
value = @oauth_application_params[oauth_applications_backchannel_logout_session_required_column]
|
168
|
+
@oauth_application_params[oauth_applications_backchannel_logout_session_required_column] =
|
169
|
+
convert_to_boolean("backchannel_logout_session_required", value)
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
148
173
|
if (value = @oauth_application_params[oauth_applications_id_token_encrypted_response_alg_column]) &&
|
149
174
|
!oauth_jwt_jwe_algorithms_supported.include?(value)
|
150
175
|
register_throw_json_response_error("invalid_client_metadata",
|
@@ -0,0 +1,134 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rodauth/oauth"
|
4
|
+
|
5
|
+
# :nocov:
|
6
|
+
raise LoadError, "the `:oidc_frontchannel_logout` requires rodauth 2.32.0 or higher" if Rodauth::VERSION < "2.32.0"
|
7
|
+
|
8
|
+
# :nocov:
|
9
|
+
|
10
|
+
module Rodauth
|
11
|
+
Feature.define(:oidc_frontchannel_logout, :OidFrontchannelLogout) do
|
12
|
+
depends :logout, :oidc_logout_base
|
13
|
+
|
14
|
+
view "frontchannel_logout", "Logout", "frontchannel_logout"
|
15
|
+
|
16
|
+
translatable_method :oauth_frontchannel_logout_redirecting_lead, "You are being redirected..."
|
17
|
+
translatable_method :oauth_frontchannel_logout_redirecting_label, "please click %<link>s if your browser does not " \
|
18
|
+
"redirect you in a few seconds."
|
19
|
+
translatable_method :oauth_frontchannel_logout_redirecting_link_label, "here"
|
20
|
+
auth_value_method :frontchannel_logout_session_supported, true
|
21
|
+
auth_value_method :frontchannel_logout_redirect_timeout, 5
|
22
|
+
auth_value_method :oauth_applications_frontchannel_logout_uri_column, :frontchannel_logout_uri
|
23
|
+
auth_value_method :oauth_applications_frontchannel_logout_session_required_column, :frontchannel_logout_session_required
|
24
|
+
|
25
|
+
attr_reader :frontchannel_logout_urls
|
26
|
+
|
27
|
+
attr_reader :frontchannel_logout_redirect
|
28
|
+
|
29
|
+
def logout
|
30
|
+
@visited_sites = session[visited_sites_key]
|
31
|
+
|
32
|
+
super
|
33
|
+
end
|
34
|
+
|
35
|
+
def _logout_response
|
36
|
+
visited_sites = @visited_sites
|
37
|
+
|
38
|
+
return super unless visited_sites
|
39
|
+
|
40
|
+
logout_urls = db[oauth_applications_table]
|
41
|
+
.where(oauth_applications_client_id_column => visited_sites.map(&:first))
|
42
|
+
.as_hash(oauth_applications_client_id_column, oauth_applications_frontchannel_logout_uri_column)
|
43
|
+
|
44
|
+
return super if logout_urls.empty?
|
45
|
+
|
46
|
+
generate_frontchannel_logout_urls(visited_sites, logout_urls)
|
47
|
+
|
48
|
+
@frontchannel_logout_redirect = logout_redirect
|
49
|
+
|
50
|
+
set_notice_flash logout_notice_flash
|
51
|
+
return_response frontchannel_logout_view
|
52
|
+
end
|
53
|
+
|
54
|
+
# overrides rp-initiate logout response
|
55
|
+
def _oidc_logout_response
|
56
|
+
visited_sites = @visited_sites
|
57
|
+
|
58
|
+
return super unless visited_sites
|
59
|
+
|
60
|
+
logout_urls = db[oauth_applications_table]
|
61
|
+
.where(oauth_applications_client_id_column => visited_sites.map(&:first))
|
62
|
+
.as_hash(oauth_applications_client_id_column, oauth_applications_frontchannel_logout_uri_column)
|
63
|
+
|
64
|
+
return super if logout_urls.empty?
|
65
|
+
|
66
|
+
generate_frontchannel_logout_urls(visited_sites, logout_urls)
|
67
|
+
|
68
|
+
@frontchannel_logout_redirect = oidc_logout_redirect
|
69
|
+
|
70
|
+
set_notice_flash logout_notice_flash
|
71
|
+
return_response frontchannel_logout_view
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def generate_frontchannel_logout_urls(visited_sites, logout_urls)
|
77
|
+
@frontchannel_logout_urls = logout_urls.flat_map do |client_id, logout_url|
|
78
|
+
next unless logout_url
|
79
|
+
|
80
|
+
sids = visited_sites.select { |cid, _| cid == client_id }.map(&:last)
|
81
|
+
|
82
|
+
sids.map do |sid|
|
83
|
+
logout_url = URI(logout_url)
|
84
|
+
|
85
|
+
if sid
|
86
|
+
query = logout_url.query
|
87
|
+
query = if query
|
88
|
+
URI.decode_www_form(query)
|
89
|
+
else
|
90
|
+
[]
|
91
|
+
end
|
92
|
+
query << ["iss", oauth_jwt_issuer]
|
93
|
+
query << ["sid", sid]
|
94
|
+
logout_url.query = URI.encode_www_form(query)
|
95
|
+
end
|
96
|
+
|
97
|
+
logout_url
|
98
|
+
end
|
99
|
+
end.compact
|
100
|
+
end
|
101
|
+
|
102
|
+
def id_token_claims(oauth_grant, signing_algorithm)
|
103
|
+
claims = super
|
104
|
+
|
105
|
+
return claims unless oauth_application[oauth_applications_frontchannel_logout_uri_column]
|
106
|
+
|
107
|
+
session_id_in_claims(oauth_grant, claims)
|
108
|
+
|
109
|
+
claims
|
110
|
+
end
|
111
|
+
|
112
|
+
def should_set_oauth_application_in_visited_sites?
|
113
|
+
true
|
114
|
+
end
|
115
|
+
|
116
|
+
def should_set_sid_in_visited_sites?(oauth_application)
|
117
|
+
super || requires_frontchannel_logout_session?(oauth_application)
|
118
|
+
end
|
119
|
+
|
120
|
+
def requires_frontchannel_logout_session?(oauth_application)
|
121
|
+
(
|
122
|
+
oauth_application &&
|
123
|
+
oauth_application[oauth_applications_frontchannel_logout_session_required_column]
|
124
|
+
) || frontchannel_logout_session_supported
|
125
|
+
end
|
126
|
+
|
127
|
+
def oauth_server_metadata_body(*)
|
128
|
+
super.tap do |data|
|
129
|
+
data[:frontchannel_logout_supported] = true
|
130
|
+
data[:frontchannel_logout_session_supported] = frontchannel_logout_session_supported
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rodauth/oauth"
|
4
|
+
|
5
|
+
module Rodauth
|
6
|
+
Feature.define(:oidc_logout_base, :OidcLogoutBase) do
|
7
|
+
depends :oidc
|
8
|
+
|
9
|
+
session_key :visited_sites_key, :visited_sites
|
10
|
+
|
11
|
+
private
|
12
|
+
|
13
|
+
# set application/sid in visited sites when required
|
14
|
+
def create_oauth_grant(create_params = {})
|
15
|
+
sid_in_visited_sites
|
16
|
+
|
17
|
+
super
|
18
|
+
end
|
19
|
+
|
20
|
+
def active_sessions?(session_id)
|
21
|
+
!active_sessions_ds.where(active_sessions_session_id_column => session_id).empty?
|
22
|
+
end
|
23
|
+
|
24
|
+
def session_id_in_claims(oauth_grant, claims)
|
25
|
+
oauth_application_in_visited_sites do
|
26
|
+
if should_set_sid_in_visited_sites?(oauth_application)
|
27
|
+
# id_token or token response types
|
28
|
+
session_id = if (sess = session[session_id_session_key])
|
29
|
+
compute_hmac(sess)
|
30
|
+
else
|
31
|
+
# code response type
|
32
|
+
ds = db[active_sessions_table]
|
33
|
+
ds = ds.where(active_sessions_account_id_column => oauth_grant[oauth_grants_account_id_column])
|
34
|
+
ds = ds.order(Sequel.desc(active_sessions_last_use_column))
|
35
|
+
ds.get(active_sessions_session_id_column)
|
36
|
+
end
|
37
|
+
|
38
|
+
claims[:sid] = session_id
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def oauth_application_in_visited_sites
|
44
|
+
visited_sites = session[visited_sites_key] || []
|
45
|
+
|
46
|
+
session_id = yield
|
47
|
+
|
48
|
+
visited_site = [oauth_application[oauth_applications_client_id_column], session_id]
|
49
|
+
|
50
|
+
return if visited_sites.include?(visited_site)
|
51
|
+
|
52
|
+
visited_sites << visited_site
|
53
|
+
set_session_value(visited_sites_key, visited_sites)
|
54
|
+
end
|
55
|
+
|
56
|
+
def sid_in_visited_sites
|
57
|
+
return unless should_set_oauth_application_in_visited_sites?
|
58
|
+
|
59
|
+
oauth_application_in_visited_sites do
|
60
|
+
if should_set_sid_in_visited_sites?(oauth_application)
|
61
|
+
ds = active_sessions_ds.order(Sequel.desc(active_sessions_last_use_column))
|
62
|
+
|
63
|
+
ds.get(active_sessions_session_id_column)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
def should_set_oauth_application_in_visited_sites?
|
69
|
+
false
|
70
|
+
end
|
71
|
+
|
72
|
+
def should_set_sid_in_visited_sites?(*)
|
73
|
+
false
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|