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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -10
  3. data/doc/release_notes/1_4_0.md +49 -0
  4. data/lib/generators/rodauth/oauth/templates/app/views/rodauth/authorize.html.erb +23 -23
  5. data/lib/generators/rodauth/oauth/templates/app/views/rodauth/frontchannel_logout.html.erb +10 -0
  6. data/lib/generators/rodauth/oauth/templates/app/views/rodauth/oauth_applications.html.erb +1 -1
  7. data/lib/generators/rodauth/oauth/templates/db/migrate/create_rodauth_oauth.rb +20 -0
  8. data/lib/generators/rodauth/oauth/views_generator.rb +2 -2
  9. data/lib/rodauth/features/oauth_application_management.rb +1 -1
  10. data/lib/rodauth/features/oauth_assertion_base.rb +1 -1
  11. data/lib/rodauth/features/oauth_authorize_base.rb +1 -1
  12. data/lib/rodauth/features/oauth_base.rb +31 -30
  13. data/lib/rodauth/features/oauth_device_code_grant.rb +2 -2
  14. data/lib/rodauth/features/oauth_dynamic_client_registration.rb +1 -0
  15. data/lib/rodauth/features/oauth_grant_management.rb +1 -1
  16. data/lib/rodauth/features/oauth_jwt.rb +3 -3
  17. data/lib/rodauth/features/oauth_jwt_base.rb +15 -10
  18. data/lib/rodauth/features/oauth_jwt_bearer_grant.rb +1 -1
  19. data/lib/rodauth/features/oauth_jwt_jwks.rb +1 -1
  20. data/lib/rodauth/features/oauth_resource_server.rb +1 -1
  21. data/lib/rodauth/features/oauth_saml_bearer_grant.rb +79 -47
  22. data/lib/rodauth/features/oauth_tls_client_auth.rb +1 -1
  23. data/lib/rodauth/features/oauth_token_introspection.rb +1 -1
  24. data/lib/rodauth/features/oauth_token_revocation.rb +1 -1
  25. data/lib/rodauth/features/oidc.rb +27 -8
  26. data/lib/rodauth/features/oidc_backchannel_logout.rb +120 -0
  27. data/lib/rodauth/features/oidc_dynamic_client_registration.rb +25 -0
  28. data/lib/rodauth/features/oidc_frontchannel_logout.rb +134 -0
  29. data/lib/rodauth/features/oidc_logout_base.rb +76 -0
  30. data/lib/rodauth/features/oidc_rp_initiated_logout.rb +29 -6
  31. data/lib/rodauth/features/oidc_session_management.rb +89 -0
  32. data/lib/rodauth/oauth/http_extensions.rb +1 -1
  33. data/lib/rodauth/oauth/version.rb +1 -1
  34. data/locales/en.yml +9 -0
  35. data/locales/pt.yml +9 -0
  36. data/templates/check_session.str +67 -0
  37. data/templates/frontchannel_logout.str +17 -0
  38. 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
- auth_value_methods(
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
- saml = saml_assertion(assertion)
44
+ parse_saml_assertion(assertion)
36
45
 
37
- return unless saml
46
+ return unless @saml_settings
38
47
 
39
48
  db[oauth_applications_table].where(
40
- oauth_applications_homepage_url_column => saml.issuers
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
- saml = saml_assertion(assertion)
54
+ parse_saml_assertion(assertion)
46
55
 
47
- return unless saml
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 => saml.nameid
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
- saml = saml_assertion(assertion)
65
+ parse_saml_assertion(assertion)
56
66
 
57
- return unless saml
67
+ return unless @assertion
58
68
 
59
- account_from_bearer_assertion_subject(saml.nameid)
69
+ account_from_bearer_assertion_subject(@assertion.nameid)
60
70
  end
61
71
 
62
- def saml_assertion(assertion)
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
- response = OneLogin::RubySaml::Response.new(assertion, settings: settings, skip_recipient_check: true)
75
+ # issuer
76
+ settings.idp_entity_id = saml_settings[oauth_saml_settings_issuer_column]
74
77
 
75
- # 3. he Assertion MUST have an expiry that limits the time window ...
76
- # 4. The Assertion MUST have an expiry that limits the time window ...
77
- # 5. The <Subject> element MUST contain at least one ...
78
- # 6. The authorization server MUST reject the entire Assertion if the ...
79
- # 7. If the Assertion issuer directly authenticated the subject, ...
80
- redirect_response_error("invalid_grant") unless response.is_valid?
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
- # In order to issue an access token response as described in OAuth 2.0
83
- # [RFC6749] or to rely on an Assertion for client authentication, the
84
- # authorization server MUST validate the Assertion according to the
85
- # criteria below.
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
- # 2. in addition to the URI references
92
- # discussed there, the token endpoint URL of the authorization
93
- # server MAY be used as a URI that identifies the authorization
94
- # server as an intended audience. The authorization server MUST
95
- # reject any Assertion that does not contain its own identity as
96
- # the intended audience.
97
- redirect_response_error("invalid_grant") if response.audiences && !response.audiences.include?(token_url)
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|
@@ -7,7 +7,7 @@ require "rodauth/oauth"
7
7
 
8
8
  module Rodauth
9
9
  Feature.define(:oauth_tls_client_auth, :OauthTlsClientAuth) do
10
- depends :oauth_jwt_base
10
+ depends :oauth_base
11
11
 
12
12
  auth_value_method :oauth_tls_client_certificate_bound_access_tokens, false
13
13
 
@@ -9,7 +9,7 @@ module Rodauth
9
9
 
10
10
  before "introspect"
11
11
 
12
- auth_value_methods(
12
+ auth_methods(
13
13
  :resource_owner_identifier
14
14
  )
15
15
 
@@ -50,7 +50,7 @@ module Rodauth
50
50
  end
51
51
  end
52
52
 
53
- redirect_response_error("invalid_request", request.referer || "/")
53
+ redirect_response_error("invalid_request")
54
54
  end
55
55
  end
56
56
 
@@ -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 :account_expiration, :oauth_jwt, :oauth_jwt_jwks, :oauth_authorization_code_grant, :oauth_implicit_grant
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
- return super unless subject_type == "pairwise"
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(oauth_grant, client_application = oauth_application)
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
- account_ids = oauth_grant.values_at(oauth_grants_resource_owner_columns)
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
- # 5.4 - However, when no Access Token is issued (which is the case for the response_type value id_token),
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