diff --git a/README.md b/README.md index e59b6c4f..2a8fa92b 100644 --- a/README.md +++ b/README.md @@ -70,32 +70,33 @@ end ### Options Overview -| Field | Description | Required | Default | Example/Options | -|------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------------------------------|-----------------------------------------------------| -| name | Arbitrary string to identify connection and identify it from other openid_connect providers | no | String: openid_connect | :my_idp | -| issuer | Root url for the authorization server | yes | | https://myprovider.com | -| discovery | Should OpenID discovery be used. This is recommended if the IDP provides a discovery endpoint. See client config for how to manually enter discovered values. | no | false | one of: true, false | -| client_auth_method | Which authentication method to use to authenticate your app with the authorization server | no | Sym: basic | "basic", "jwks" | -| scope | Which OpenID scopes to include (:openid is always required) | no | Array [:openid] | [:openid, :profile, :email] | -| response_type | Which OAuth2 response type to use with the authorization request | no | String: code | one of: 'code', 'id_token' | -| state | A value to be used for the OAuth2 state parameter on the authorization request. Can be a proc that generates a string. | no | Random 16 character string | Proc.new { SecureRandom.hex(32) } | +| Field | Description | Required | Default | Example/Options | +|------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|-------------------------------|-----------------------------------------------------| +| name | Arbitrary string to identify connection and identify it from other openid_connect providers | no | String: openid_connect | :my_idp | +| issuer | Root url for the authorization server | yes | | https://myprovider.com | +| discovery | Should OpenID discovery be used. This is recommended if the IDP provides a discovery endpoint. See client config for how to manually enter discovered values. | no | false | one of: true, false | +| client_auth_method | Which authentication method to use to authenticate your app with the authorization server | no | Sym: basic | "basic", "jwks" | +| scope | Which OpenID scopes to include (:openid is always required) | no | Array [:openid] | [:openid, :profile, :email] | +| response_type | Which OAuth2 response type to use with the authorization request | no | String: code | one of: 'code', 'id_token' | +| state | A value to be used for the OAuth2 state parameter on the authorization request. Can be a proc that generates a string. | no | Random 16 character string | Proc.new { SecureRandom.hex(32) } | | require_state | Should the callback phase require that a state is present. If `send_state` is true, then the callback state must match the authorize state. This is recommended, not required by the OIDC specification. | no | true | false | -| send_state | Should the authorize phase send a `state` parameter - this is recommended, not required by the OIDC specification | no | true | false | -| response_mode | The response mode per [spec](https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html) | no | nil | one of: :query, :fragment, :form_post, :web_message | -| display | An optional parameter to the authorization request to determine how the authorization and consent page | no | nil | one of: :page, :popup, :touch, :wap | -| prompt | An optional parameter to the authorization request to determine what pages the user will be shown | no | nil | one of: :none, :login, :consent, :select_account | -| send_scope_to_token_endpoint | Should the scope parameter be sent to the authorization token endpoint? | no | true | one of: true, false | -| post_logout_redirect_uri | The logout redirect uri to use per the [session management draft](https://openid.net/specs/openid-connect-session-1_0.html) | no | empty | https://myapp.com/logout/callback | -| uid_field | The field of the user info response to be used as a unique id | no | 'sub' | "sub", "preferred_username" | -| extra_authorize_params | A hash of extra fixed parameters that will be merged to the authorization request | no | Hash | {"tenant" => "common"} | -| allow_authorize_params | A list of allowed dynamic parameters that will be merged to the authorization request | no | Array | [:screen_name] | -| pkce | Enable [PKCE flow](https://oauth.net/2/pkce/) | no | false | one of: true, false | -| pkce_verifier | Specify a custom PKCE verifier code. | no | A random 128-char string | Proc.new { SecureRandom.hex(64) } | -| pkce_options | Specify a custom implementation of the PKCE code challenge/method. | no | SHA256(code_challenge) in hex | Proc to customise the code challenge generation | -| client_options | A hash of client options detailed in its own section | yes | | | -| jwt_secret_base64 | For HMAC with SHA2 (e.g. HS256) signing algorithms, specify the base64-encoded secret used to sign the JWT token. Defaults to the OAuth2 client secret if not specified. | no | client_options.secret | "bXlzZWNyZXQ=\n" | -| logout_path | The log out is only triggered when the request path ends on this path | no | '/logout' | '/sign_out' | -| acr_values | Authentication Class Reference (ACR) values to be passed to the authorize_uri to enforce a specific level, see [RFC9470](https://www.rfc-editor.org/rfc/rfc9470.html) | no | nil | "c1 c2" | +| send_state | Should the authorize phase send a `state` parameter - this is recommended, not required by the OIDC specification | no | true | false | +| response_mode | The response mode per [spec](https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html) | no | nil | one of: :query, :fragment, :form_post, :web_message | +| display | An optional parameter to the authorization request to determine how the authorization and consent page | no | nil | one of: :page, :popup, :touch, :wap | +| prompt | An optional parameter to the authorization request to determine what pages the user will be shown | no | nil | one of: :none, :login, :consent, :select_account | +| send_scope_to_token_endpoint | Should the scope parameter be sent to the authorization token endpoint? | no | true | one of: true, false | +| post_logout_redirect_uri | The logout redirect uri to use per the [session management draft](https://openid.net/specs/openid-connect-session-1_0.html) | no | empty | https://myapp.com/logout/callback | +| uid_field | The field of the user info response to be used as a unique id | no | 'sub' | "sub", "preferred_username" | +| extra_authorize_params | A hash of extra fixed parameters that will be merged to the authorization request | no | Hash | {"tenant" => "common"} | +| allow_authorize_params | A list of allowed dynamic parameters that will be merged to the authorization request | no | Array | [:screen_name] | +| allow_logout_params | A list of allowed dynamic parameters that will be merged to the logout request | no | Array | [:state, :client_id] | +| pkce | Enable [PKCE flow](https://oauth.net/2/pkce/) | no | false | one of: true, false | +| pkce_verifier | Specify a custom PKCE verifier code. | no | A random 128-char string | Proc.new { SecureRandom.hex(64) } | +| pkce_options | Specify a custom implementation of the PKCE code challenge/method. | no | SHA256(code_challenge) in hex | Proc to customise the code challenge generation | +| client_options | A hash of client options detailed in its own section | yes | | | +| jwt_secret_base64 | For HMAC with SHA2 (e.g. HS256) signing algorithms, specify the base64-encoded secret used to sign the JWT token. Defaults to the OAuth2 client secret if not specified. | no | client_options.secret | "bXlzZWNyZXQ=\n" | +| logout_path | The log out is only triggered when the request path ends on this path | no | '/logout' | '/sign_out' | +| acr_values | Authentication Class Reference (ACR) values to be passed to the authorize_uri to enforce a specific level, see [RFC9470](https://www.rfc-editor.org/rfc/rfc9470.html) | no | nil | "c1 c2" | ### Client Config Options diff --git a/lib/omniauth/strategies/openid_connect.rb b/lib/omniauth/strategies/openid_connect.rb index 73dd0fe0..60d6f4d8 100644 --- a/lib/omniauth/strategies/openid_connect.rb +++ b/lib/omniauth/strategies/openid_connect.rb @@ -60,6 +60,7 @@ class OpenIDConnect # rubocop:disable Metrics/ClassLength option :post_logout_redirect_uri option :extra_authorize_params, {} option :allow_authorize_params, [] + option :allow_logout_params, [] option :uid_field, 'sub' option :pkce, false option :pkce_verifier, nil @@ -172,7 +173,7 @@ def end_session_uri return unless end_session_endpoint_is_valid? end_session_uri = URI(client_options.end_session_endpoint) - end_session_uri.query = encoded_post_logout_redirect_uri + end_session_uri.query = encoded_logout_parameters end_session_uri.to_s end @@ -427,12 +428,30 @@ def redirect_uri "#{ client_options.redirect_uri }?redirect_uri=#{ CGI.escape(params['redirect_uri']) }" end - def encoded_post_logout_redirect_uri - return unless options.post_logout_redirect_uri + def encoded_logout_parameters + opts = { + post_logout_redirect_uri: options.post_logout_redirect_uri, + id_token_hint: params['id_token_hint'], + logout_hint: params['logout_hint'], + ui_locales: params['ui_locales'], + } - URI.encode_www_form( - post_logout_redirect_uri: options.post_logout_redirect_uri - ) + options.allow_logout_params.each do |key| + next if opts.key?(key) + + opts[key] = case key.to_sym + when :client_id + client_options.identifier + when :state + new_state + else + request.params[key] + end + end + logout_params = opts.reject { |_k, v| v.nil? } + return if logout_params.empty? + + URI.encode_www_form(logout_params) end def end_session_endpoint_is_valid? diff --git a/test/lib/omniauth/strategies/openid_connect_test.rb b/test/lib/omniauth/strategies/openid_connect_test.rb index ffa9f708..32b15ce5 100644 --- a/test/lib/omniauth/strategies/openid_connect_test.rb +++ b/test/lib/omniauth/strategies/openid_connect_test.rb @@ -29,7 +29,7 @@ def test_logout_phase_with_discovery issuer.stubs(:issuer).returns('https://example.com/') ::OpenIDConnect::Discovery::Provider.stubs(:discover!).returns(issuer) - config = stub('OpenIDConnect::Discovery::Provder::Config') + config = stub('OpenIDConnect::Discovery::Provider::Config') config.stubs(:authorization_endpoint).returns('https://example.com/authorization') config.stubs(:token_endpoint).returns('https://example.com/token') config.stubs(:userinfo_endpoint).returns('https://example.com/userinfo') @@ -54,7 +54,86 @@ def test_logout_phase_with_discovery_and_post_logout_redirect_uri issuer.stubs(:issuer).returns('https://example.com/') ::OpenIDConnect::Discovery::Provider.stubs(:discover!).returns(issuer) - config = stub('OpenIDConnect::Discovery::Provder::Config') + config = stub('OpenIDConnect::Discovery::Provider::Config') + config.stubs(:authorization_endpoint).returns('https://example.com/authorization') + config.stubs(:token_endpoint).returns('https://example.com/token') + config.stubs(:userinfo_endpoint).returns('https://example.com/userinfo') + config.stubs(:jwks_uri).returns('https://example.com/jwks') + config.stubs(:end_session_endpoint).returns('https://example.com/logout') + ::OpenIDConnect::Discovery::Provider::Config.stubs(:discover!).with('https://example.com/').returns(config) + + request.stubs(:path_info).returns('/auth/openid_connect/logout') + request.stubs(:path).returns('/auth/openid_connect/logout') + + strategy.expects(:redirect).with(expected_redirect) + strategy.other_phase + end + + def test_logout_phase_with_discovery_and_id_token_hint + id_token = jwt.to_s + expected_redirect = "https://example.com/logout?id_token_hint=#{id_token}" + strategy.options.client_options.host = 'example.com' + strategy.options.discovery = true + + issuer = stub('OpenIDConnect::Discovery::Issuer') + issuer.stubs(:issuer).returns('https://example.com/') + ::OpenIDConnect::Discovery::Provider.stubs(:discover!).returns(issuer) + + config = stub('OpenIDConnect::Discovery::Provider::Config') + config.stubs(:authorization_endpoint).returns('https://example.com/authorization') + config.stubs(:token_endpoint).returns('https://example.com/token') + config.stubs(:userinfo_endpoint).returns('https://example.com/userinfo') + config.stubs(:jwks_uri).returns('https://example.com/jwks') + config.stubs(:end_session_endpoint).returns('https://example.com/logout') + ::OpenIDConnect::Discovery::Provider::Config.stubs(:discover!).with('https://example.com/').returns(config) + + request.stubs(:path_info).returns('/auth/openid_connect/logout') + request.stubs(:path).returns('/auth/openid_connect/logout') + request.stubs(:params).returns({ 'id_token_hint' => id_token }) + + strategy.expects(:redirect).with(expected_redirect) + strategy.other_phase + end + + def test_logout_phase_with_discovery_and_client_id + expected_redirect = "https://example.com/logout?client_id=#{@identifier}" + strategy.options.client_options.host = 'example.com' + strategy.options.client_options.identifier = @identifier + strategy.options.discovery = true + strategy.options.allow_logout_params = [:client_id] + + issuer = stub('OpenIDConnect::Discovery::Issuer') + issuer.stubs(:issuer).returns('https://example.com/') + ::OpenIDConnect::Discovery::Provider.stubs(:discover!).returns(issuer) + + config = stub('OpenIDConnect::Discovery::Provider::Config') + config.stubs(:authorization_endpoint).returns('https://example.com/authorization') + config.stubs(:token_endpoint).returns('https://example.com/token') + config.stubs(:userinfo_endpoint).returns('https://example.com/userinfo') + config.stubs(:jwks_uri).returns('https://example.com/jwks') + config.stubs(:end_session_endpoint).returns('https://example.com/logout') + ::OpenIDConnect::Discovery::Provider::Config.stubs(:discover!).with('https://example.com/').returns(config) + + request.stubs(:path_info).returns('/auth/openid_connect/logout') + request.stubs(:path).returns('/auth/openid_connect/logout') + + strategy.expects(:redirect).with(expected_redirect) + strategy.other_phase + end + + def test_logout_phase_with_discovery_and_state + state = 'cd73ea627b9edbe4f147425398f3151a' + expected_redirect = "https://example.com/logout?state=#{state}" + strategy.options.client_options.host = 'example.com' + strategy.options.state = -> { state } + strategy.options.discovery = true + strategy.options.allow_logout_params = [:state] + + issuer = stub('OpenIDConnect::Discovery::Issuer') + issuer.stubs(:issuer).returns('https://example.com/') + ::OpenIDConnect::Discovery::Provider.stubs(:discover!).returns(issuer) + + config = stub('OpenIDConnect::Discovery::Provider::Config') config.stubs(:authorization_endpoint).returns('https://example.com/authorization') config.stubs(:token_endpoint).returns('https://example.com/token') config.stubs(:userinfo_endpoint).returns('https://example.com/userinfo') @@ -109,7 +188,7 @@ def test_request_phase_with_discovery issuer.stubs(:issuer).returns('https://example.com/') ::OpenIDConnect::Discovery::Provider.stubs(:discover!).returns(issuer) - config = stub('OpenIDConnect::Discovery::Provder::Config') + config = stub('OpenIDConnect::Discovery::Provider::Config') config.stubs(:authorization_endpoint).returns('https://example.com/authorization') config.stubs(:token_endpoint).returns('https://example.com/token') config.stubs(:userinfo_endpoint).returns('https://example.com/userinfo') @@ -446,7 +525,7 @@ def test_callback_phase_with_discovery # rubocop:disable Metrics/AbcSize issuer.stubs(:issuer).returns('https://example.com/') ::OpenIDConnect::Discovery::Provider.stubs(:discover!).returns(issuer) - config = stub('OpenIDConnect::Discovery::Provder::Config') + config = stub('OpenIDConnect::Discovery::Provider::Config') config.stubs(:authorization_endpoint).returns('https://example.com/authorization') config.stubs(:token_endpoint).returns('https://example.com/token') config.stubs(:userinfo_endpoint).returns('https://example.com/userinfo') @@ -490,7 +569,7 @@ def test_callback_phase_with_send_state_disabled # rubocop:disable Metrics/AbcSi issuer.stubs(:issuer).returns('https://example.com/') ::OpenIDConnect::Discovery::Provider.stubs(:discover!).returns(issuer) - config = stub('OpenIDConnect::Discovery::Provder::Config') + config = stub('OpenIDConnect::Discovery::Provider::Config') config.stubs(:authorization_endpoint).returns('https://example.com/authorization') config.stubs(:token_endpoint).returns('https://example.com/token') config.stubs(:userinfo_endpoint).returns('https://example.com/userinfo') @@ -533,7 +612,7 @@ def test_callback_phase_with_no_state_without_state_verification # rubocop:disab issuer.stubs(:issuer).returns('https://example.com/') ::OpenIDConnect::Discovery::Provider.stubs(:discover!).returns(issuer) - config = stub('OpenIDConnect::Discovery::Provder::Config') + config = stub('OpenIDConnect::Discovery::Provider::Config') config.stubs(:authorization_endpoint).returns('https://example.com/authorization') config.stubs(:token_endpoint).returns('https://example.com/token') config.stubs(:userinfo_endpoint).returns('https://example.com/userinfo')