rodauth-oauth 1.3.2 → 1.5.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -10
  3. data/doc/release_notes/1_4_0.md +57 -0
  4. data/doc/release_notes/1_5_0.md +20 -0
  5. data/lib/generators/rodauth/oauth/templates/app/views/rodauth/authorize.html.erb +28 -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 +37 -1
  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 +49 -38
  14. data/lib/rodauth/features/oauth_device_code_grant.rb +2 -2
  15. data/lib/rodauth/features/oauth_dpop.rb +410 -0
  16. data/lib/rodauth/features/oauth_dynamic_client_registration.rb +12 -2
  17. data/lib/rodauth/features/oauth_grant_management.rb +1 -1
  18. data/lib/rodauth/features/oauth_jwt.rb +12 -13
  19. data/lib/rodauth/features/oauth_jwt_base.rb +57 -34
  20. data/lib/rodauth/features/oauth_jwt_bearer_grant.rb +1 -1
  21. data/lib/rodauth/features/oauth_jwt_jwks.rb +1 -1
  22. data/lib/rodauth/features/oauth_resource_indicators.rb +1 -1
  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 +2 -4
  26. data/lib/rodauth/features/oauth_token_introspection.rb +3 -3
  27. data/lib/rodauth/features/oauth_token_revocation.rb +1 -1
  28. data/lib/rodauth/features/oidc.rb +32 -11
  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 +91 -0
  35. data/lib/rodauth/oauth/database_extensions.rb +4 -0
  36. data/lib/rodauth/oauth/http_extensions.rb +1 -1
  37. data/lib/rodauth/oauth/ttl_store.rb +1 -1
  38. data/lib/rodauth/oauth/version.rb +1 -1
  39. data/locales/en.yml +19 -0
  40. data/locales/pt.yml +9 -0
  41. data/templates/authorize.str +1 -0
  42. data/templates/check_session.str +67 -0
  43. data/templates/frontchannel_logout.str +17 -0
  44. metadata +14 -2
@@ -7,7 +7,7 @@ module Rodauth
7
7
  Feature.define(:oauth_jwt_jwks, :OauthJwtJwks) do
8
8
  depends :oauth_jwt_base
9
9
 
10
- auth_value_methods(:jwks_set)
10
+ auth_methods(:jwks_set)
11
11
 
12
12
  auth_server_route(:jwks) do |r|
13
13
  before_jwks_route
@@ -116,7 +116,7 @@ module Rodauth
116
116
 
117
117
  module IndicatorIntrospection
118
118
  def json_token_introspect_payload(grant)
119
- return super unless grant[oauth_grants_id_column]
119
+ return super unless grant && grant[oauth_grants_id_column]
120
120
 
121
121
  payload = super
122
122
 
@@ -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
 
@@ -112,9 +112,7 @@ module Rodauth
112
112
 
113
113
  return claims unless grant_or_claims && grant_or_claims[oauth_grants_certificate_thumbprint_column]
114
114
 
115
- claims[:cnf] = {
116
- "x5t#S256" => grant_or_claims[oauth_grants_certificate_thumbprint_column]
117
- }
115
+ (claims[:cnf] ||= {})["x5t#S256"] = grant_or_claims[oauth_grants_certificate_thumbprint_column]
118
116
 
119
117
  claims
120
118
  end
@@ -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
 
@@ -66,7 +66,7 @@ module Rodauth
66
66
  scope: grant_or_claims["scope"],
67
67
  client_id: grant_or_claims["client_id"],
68
68
  username: resource_owner_identifier(grant_or_claims),
69
- token_type: "access_token",
69
+ token_type: oauth_token_type.capitalize,
70
70
  exp: grant_or_claims["exp"],
71
71
  iat: grant_or_claims["iat"],
72
72
  nbf: grant_or_claims["nbf"],
@@ -99,7 +99,7 @@ module Rodauth
99
99
  private
100
100
 
101
101
  def require_oauth_application_for_introspect
102
- (token = ((v = request.env["HTTP_AUTHORIZATION"]) && v[/\A *Bearer (.*)\Z/, 1]))
102
+ (token = (v = request.env["HTTP_AUTHORIZATION"]) && v[/\A *Bearer (.*)\Z/, 1])
103
103
 
104
104
  return require_oauth_application unless token
105
105
 
@@ -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
@@ -294,10 +302,10 @@ module Rodauth
294
302
  # The value is a JSON object listing the requested Claims.
295
303
  claims = JSON.parse(claims)
296
304
 
297
- claims.each do |_, individual_claims|
305
+ claims.each_value do |individual_claims|
298
306
  redirect_response_error("invalid_request") unless individual_claims.is_a?(Hash)
299
307
 
300
- individual_claims.each do |_, claim|
308
+ individual_claims.each_value do |claim|
301
309
  redirect_response_error("invalid_request") unless claim.nil? || individual_claims.is_a?(Hash)
302
310
  end
303
311
  end
@@ -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})"
@@ -405,7 +419,9 @@ module Rodauth
405
419
 
406
420
  login_cookie_opts = Hash[oauth_prompt_login_cookie_options]
407
421
  login_cookie_opts[:value] = "login"
408
- login_cookie_opts[:expires] = convert_timestamp(Time.now + oauth_prompt_login_interval) # 15 minutes
422
+ if oauth_prompt_login_interval
423
+ login_cookie_opts[:expires] = convert_timestamp(Time.now + oauth_prompt_login_interval) # 15 minutes
424
+ end
409
425
  ::Rack::Utils.set_cookie_header!(response.headers, oauth_prompt_login_cookie_key, login_cookie_opts)
410
426
 
411
427
  redirect require_login_redirect
@@ -516,7 +532,12 @@ module Rodauth
516
532
  end
517
533
  end
518
534
 
519
- # 5.4 - However, when no Access Token is issued (which is the case for the response_type value id_token),
535
+ # OpenID Connect Core 1.0's 5.4 Requesting Claims using Scope Values:
536
+ # If standard claims (profile, email, etc) are requested as scope values in the Authorization Request,
537
+ # include in the response.
538
+ include_claims ||= (OIDC_SCOPES_MAP.keys & oauth_scopes).any?
539
+
540
+ # However, when no Access Token is issued (which is the case for the response_type value id_token),
520
541
  # the resulting Claims are returned in the ID Token.
521
542
  fill_with_account_claims(id_claims, account, oauth_scopes, param_or_nil("claims_locales")) if include_claims
522
543
 
@@ -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