diff --git a/README.md b/README.md index de34f212..5a327a8c 100644 --- a/README.md +++ b/README.md @@ -47,24 +47,27 @@ config.omniauth :openid_connect, { ### 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<sym> [: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) } | -| 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 authrization 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] | -| client_options | A hash of client options detailed in its own section | yes | | | +| 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<sym> [: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) } | +| 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 authrization 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 | | | ### Client Config Options @@ -94,7 +97,7 @@ These are the configuration options for the client_options hash of the configura * `response_type` tells the authorization server which grant type the application wants to use, currently, only `:code` (Authorization Code grant) and `:id_token` (Implicit grant) are valid. - * If you want to pass `state` paramete by yourself. You can set Proc Object. + * If you want to pass `state` parameter by yourself. You can set Proc Object. e.g. `state: Proc.new { SecureRandom.hex(32) }` * `nonce` is optional. If don't want to pass "nonce" parameter to provider, You should specify `false` to `send_nonce` option. (default true) @@ -116,6 +119,11 @@ These are the configuration options for the client_options hash of the configura property can be used to add the attribute to the token request. Initial value is `true`, which means that the scope attribute is included by default. +### Additional notes + * In some cases, you may want to go straight to the callback phase - e.g. when requested by a stateless client, like a mobile app. + In such example, the session is empty, so you have to forward certain parameters received from the client. + Currently supported one is `code_verifier` - simply provide it as the `/callback` request parameter. + For the full low down on OpenID Connect, please check out [the spec](http://openid.net/specs/openid-connect-core-1_0.html). diff --git a/lib/omniauth/strategies/openid_connect.rb b/lib/omniauth/strategies/openid_connect.rb index 9a530250..41c55322 100644 --- a/lib/omniauth/strategies/openid_connect.rb +++ b/lib/omniauth/strategies/openid_connect.rb @@ -57,6 +57,14 @@ class OpenIDConnect option :extra_authorize_params, {} option :allow_authorize_params, [] option :uid_field, 'sub' + option :pkce, false + option :pkce_verifier, nil + option :pkce_options, { + code_challenge: proc { |verifier| + Base64.urlsafe_encode64(Digest::SHA2.digest(verifier), padding: false) + }, + code_challenge_method: 'S256', + } def uid user_info.raw_attributes[options.uid_field.to_sym] || user_info.sub @@ -178,6 +186,13 @@ def authorize_uri opts[key] = request.params[key.to_s] unless opts.key?(key) end + if options.pkce + verifier = options.pkce_verifier ? options.pkce_verifier.call : SecureRandom.hex(64) + + opts.merge!(pkce_authorize_params(verifier)) + session['omniauth.pkce.verifier'] = verifier + end + client.authorization_uri(opts.reject { |_k, v| v.nil? }) end @@ -187,6 +202,14 @@ def public_key key_or_secret || config.jwks end + def pkce_authorize_params(verifier) + # NOTE: see https://tools.ietf.org/html/rfc7636#appendix-A + { + code_challenge: options.pkce_options[:code_challenge].call(verifier), + code_challenge_method: options.pkce_options[:code_challenge_method], + } + end + private def issuer @@ -220,11 +243,14 @@ def user_info def access_token return @access_token if @access_token - @access_token = client.access_token!( + token_request_params = { scope: (options.scope if options.send_scope_to_token_endpoint), - client_auth_method: options.client_auth_method - ) + client_auth_method: options.client_auth_method, + } + + token_request_params[:code_verifier] = params['code_verifier'] || session.delete('omniauth.pkce.verifier') if options.pkce + @access_token = client.access_token!(token_request_params) verify_id_token!(@access_token.id_token) if configured_response_type == 'code' @access_token diff --git a/test/lib/omniauth/strategies/openid_connect_test.rb b/test/lib/omniauth/strategies/openid_connect_test.rb index fbf5dd44..c204883f 100644 --- a/test/lib/omniauth/strategies/openid_connect_test.rb +++ b/test/lib/omniauth/strategies/openid_connect_test.rb @@ -632,6 +632,41 @@ def test_id_token_auth_hash assert auth_hash.key?('extra') assert auth_hash['extra'].key?('raw_info') end + + def test_option_pkce + strategy.options.client_options[:host] = 'example.com' + + # test pkce disabled + strategy.options.pkce = false + + assert((strategy.authorize_uri !~ /code_challenge=/), 'URI must not contain code challenge param') + assert((strategy.authorize_uri !~ /code_challenge_method=/), 'URI must not contain code challenge method param') + + # test pkce enabled with default opts + strategy.options.pkce = true + + assert(strategy.authorize_uri =~ /code_challenge=/, 'URI must contain code challenge param') + assert(strategy.authorize_uri =~ /code_challenge_method=/, 'URI must contain code challenge method param') + + # test pkce with custom verifier code + strategy.options.pkce_verifier = proc { 'dummy_verifier' } + code_challenge_value = Base64.urlsafe_encode64( + Digest::SHA2.digest(strategy.options.pkce_verifier.call), + padding: false + ) + + assert(strategy.authorize_uri =~ /#{Regexp.quote(code_challenge_value)}/, 'URI must contain code challenge value') + + # test pkce with custom options and plain text code + strategy.options.pkce_options = + { + code_challenge: proc { |verifier| verifier }, + code_challenge_method: 'plain', + } + + assert(strategy.authorize_uri =~ /#{Regexp.quote(strategy.options.pkce_verifier.call)}/, + 'URI must contain code challenge value') + end end end end