rodauth-oauth 1.3.1 → 1.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +19 -12
  3. data/doc/release_notes/1_3_2.md +14 -0
  4. data/doc/release_notes/1_4_0.md +49 -0
  5. data/lib/generators/rodauth/oauth/templates/app/views/rodauth/authorize.html.erb +23 -23
  6. data/lib/generators/rodauth/oauth/templates/app/views/rodauth/frontchannel_logout.html.erb +10 -0
  7. data/lib/generators/rodauth/oauth/templates/app/views/rodauth/oauth_applications.html.erb +1 -1
  8. data/lib/generators/rodauth/oauth/templates/db/migrate/create_rodauth_oauth.rb +21 -0
  9. data/lib/generators/rodauth/oauth/views_generator.rb +2 -2
  10. data/lib/rodauth/features/oauth_application_management.rb +1 -1
  11. data/lib/rodauth/features/oauth_assertion_base.rb +1 -1
  12. data/lib/rodauth/features/oauth_authorize_base.rb +1 -1
  13. data/lib/rodauth/features/oauth_base.rb +31 -30
  14. data/lib/rodauth/features/oauth_device_code_grant.rb +2 -2
  15. data/lib/rodauth/features/oauth_dynamic_client_registration.rb +9 -0
  16. data/lib/rodauth/features/oauth_grant_management.rb +1 -1
  17. data/lib/rodauth/features/oauth_jwt.rb +3 -3
  18. data/lib/rodauth/features/oauth_jwt_base.rb +23 -12
  19. data/lib/rodauth/features/oauth_jwt_bearer_grant.rb +1 -1
  20. data/lib/rodauth/features/oauth_jwt_jwks.rb +1 -1
  21. data/lib/rodauth/features/oauth_jwt_secured_authorization_request.rb +25 -6
  22. data/lib/rodauth/features/oauth_pushed_authorization_request.rb +33 -24
  23. data/lib/rodauth/features/oauth_resource_server.rb +1 -1
  24. data/lib/rodauth/features/oauth_saml_bearer_grant.rb +79 -47
  25. data/lib/rodauth/features/oauth_tls_client_auth.rb +1 -1
  26. data/lib/rodauth/features/oauth_token_introspection.rb +1 -1
  27. data/lib/rodauth/features/oauth_token_revocation.rb +1 -1
  28. data/lib/rodauth/features/oidc.rb +27 -8
  29. data/lib/rodauth/features/oidc_backchannel_logout.rb +120 -0
  30. data/lib/rodauth/features/oidc_dynamic_client_registration.rb +25 -0
  31. data/lib/rodauth/features/oidc_frontchannel_logout.rb +134 -0
  32. data/lib/rodauth/features/oidc_logout_base.rb +76 -0
  33. data/lib/rodauth/features/oidc_rp_initiated_logout.rb +29 -6
  34. data/lib/rodauth/features/oidc_session_management.rb +89 -0
  35. data/lib/rodauth/oauth/http_extensions.rb +1 -1
  36. data/lib/rodauth/oauth/version.rb +1 -1
  37. data/locales/en.yml +9 -0
  38. data/locales/pt.yml +9 -0
  39. data/templates/check_session.str +67 -0
  40. data/templates/frontchannel_logout.str +17 -0
  41. metadata +13 -2
@@ -9,13 +9,15 @@ module Rodauth
9
9
  depends :oauth_authorize_base, :oauth_jwt_base
10
10
 
11
11
  auth_value_method :oauth_require_request_uri_registration, false
12
+ auth_value_method :oauth_require_signed_request_object, false
12
13
  auth_value_method :oauth_request_object_signing_alg_allow_none, false
13
14
 
14
- auth_value_method :oauth_applications_request_uris_column, :request_uris
15
-
16
- auth_value_method :oauth_applications_request_object_signing_alg_column, :request_object_signing_alg
17
- auth_value_method :oauth_applications_request_object_encryption_alg_column, :request_object_encryption_alg
18
- auth_value_method :oauth_applications_request_object_encryption_enc_column, :request_object_encryption_enc
15
+ %i[
16
+ request_uris require_signed_request_object request_object_signing_alg
17
+ request_object_encryption_alg request_object_encryption_enc
18
+ ].each do |column|
19
+ auth_value_method :"oauth_applications_#{column}_column", column
20
+ end
19
21
 
20
22
  translatable_method :oauth_invalid_request_object_message, "request object is invalid"
21
23
 
@@ -30,7 +32,13 @@ module Rodauth
30
32
 
31
33
  request_uri = param_or_nil("request_uri")
32
34
 
33
- return super unless (request_object || request_uri) && oauth_application
35
+ unless (request_object || request_uri) && oauth_application
36
+ if request.path == authorize_path && request.get? && require_signed_request_object?
37
+ redirect_response_error("invalid_request_object")
38
+ end
39
+
40
+ return super
41
+ end
34
42
 
35
43
  if request_uri
36
44
  request_uri = CGI.unescape(request_uri)
@@ -84,6 +92,14 @@ module Rodauth
84
92
  request_uris.nil? || request_uris.split(oauth_scope_separator).one? { |uri| request_uri.start_with?(uri) }
85
93
  end
86
94
 
95
+ def require_signed_request_object?
96
+ return @require_signed_request_object if defined?(@require_signed_request_object)
97
+
98
+ @require_signed_request_object = (oauth_application[oauth_applications_require_signed_request_object_column] if oauth_application)
99
+ @require_signed_request_object = oauth_require_signed_request_object if @require_signed_request_object.nil?
100
+ @require_signed_request_object
101
+ end
102
+
87
103
  def decode_request_object(request_object)
88
104
  request_sig_enc_opts = {
89
105
  jws_algorithm: oauth_application[oauth_applications_request_object_signing_alg_column],
@@ -94,6 +110,8 @@ module Rodauth
94
110
  request_sig_enc_opts[:jws_algorithm] ||= "none" if oauth_request_object_signing_alg_allow_none
95
111
 
96
112
  if request_sig_enc_opts[:jws_algorithm] == "none"
113
+ redirect_response_error("invalid_request_object") if require_signed_request_object?
114
+
97
115
  jwks = nil
98
116
  elsif (jwks = oauth_application_jwks(oauth_application))
99
117
  jwks = JSON.parse(jwks, symbolize_names: true) if jwks.is_a?(String)
@@ -118,6 +136,7 @@ module Rodauth
118
136
  data[:request_parameter_supported] = true
119
137
  data[:request_uri_parameter_supported] = true
120
138
  data[:require_request_uri_registration] = oauth_require_request_uri_registration
139
+ data[:require_signed_request_object] = oauth_require_signed_request_object
121
140
  end
122
141
  end
123
142
  end
@@ -65,32 +65,37 @@ module Rodauth
65
65
  # The request_uri authorization request parameter is one exception, and it MUST NOT be provided.
66
66
  redirect_response_error("invalid_request") if param_or_nil("request_uri")
67
67
 
68
- if (request_object = param_or_nil("request")) && features.include?(:oauth_jwt_secured_authorization_request)
69
- claims = decode_request_object(request_object)
70
-
71
- # https://datatracker.ietf.org/doc/html/rfc9126#section-3-5.3
72
- # reject the request if the authenticated client_id does not match the client_id claim in the Request Object
73
- if (client_id = claims["client_id"]) && (client_id != oauth_application[oauth_applications_client_id_column])
74
- redirect_response_error("invalid_request_object")
75
- end
76
-
77
- # requiring the iss claim to match the client_id is at the discretion of the authorization server
78
- if oauth_require_pushed_authorization_request_iss_request_object &&
79
- (iss = claims.delete("iss")) &&
80
- iss != oauth_application[oauth_applications_client_id_column]
81
- redirect_response_error("invalid_request_object")
82
- end
83
-
84
- if (aud = claims.delete("aud")) && !verify_aud(aud, oauth_jwt_issuer)
68
+ if features.include?(:oauth_jwt_secured_authorization_request)
69
+
70
+ if (request_object = param_or_nil("request"))
71
+ claims = decode_request_object(request_object)
72
+
73
+ # https://datatracker.ietf.org/doc/html/rfc9126#section-3-5.3
74
+ # reject the request if the authenticated client_id does not match the client_id claim in the Request Object
75
+ if (client_id = claims["client_id"]) && (client_id != oauth_application[oauth_applications_client_id_column])
76
+ redirect_response_error("invalid_request_object")
77
+ end
78
+
79
+ # requiring the iss claim to match the client_id is at the discretion of the authorization server
80
+ if oauth_require_pushed_authorization_request_iss_request_object &&
81
+ (iss = claims.delete("iss")) &&
82
+ iss != oauth_application[oauth_applications_client_id_column]
83
+ redirect_response_error("invalid_request_object")
84
+ end
85
+
86
+ if (aud = claims.delete("aud")) && !verify_aud(aud, oauth_jwt_issuer)
87
+ redirect_response_error("invalid_request_object")
88
+ end
89
+
90
+ claims.delete("exp")
91
+ request.params.delete("request")
92
+
93
+ claims.each do |k, v|
94
+ request.params[k.to_s] = v
95
+ end
96
+ elsif require_signed_request_object?
85
97
  redirect_response_error("invalid_request_object")
86
98
  end
87
-
88
- claims.delete("exp")
89
- request.params.delete("request")
90
-
91
- claims.each do |k, v|
92
- request.params[k.to_s] = v
93
- end
94
99
  end
95
100
 
96
101
  validate_authorize_params
@@ -118,6 +123,10 @@ module Rodauth
118
123
  request.params[k.to_s] = v
119
124
  end
120
125
 
126
+ request.params.delete("request_uri")
127
+
128
+ # we're removing the request_uri here, so the checkup for signed reqest has to be invalidated.
129
+ @require_signed_request_object = false
121
130
  elsif oauth_require_pushed_authorization_requests ||
122
131
  (oauth_application && oauth_application[oauth_applications_require_pushed_authorization_requests_column])
123
132
  redirect_authorize_error("request_uri")
@@ -8,7 +8,7 @@ module Rodauth
8
8
 
9
9
  auth_value_method :is_authorization_server?, false
10
10
 
11
- auth_value_methods(
11
+ auth_methods(
12
12
  :before_introspection_request
13
13
  )
14
14
 
@@ -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",