lib/rodauth/features/oidc.rb in rodauth-oauth-1.0.0.pre.beta1 vs lib/rodauth/features/oidc.rb in rodauth-oauth-1.0.0.pre.beta2

- old
+ new

@@ -15,10 +15,11 @@ VALID_METADATA_KEYS = %i[ issuer authorization_endpoint end_session_endpoint + backchannel_logout_session_supported token_endpoint userinfo_endpoint jwks_uri registration_endpoint scopes_supported @@ -72,30 +73,27 @@ userinfo_signed_response_alg userinfo_encrypted_response_alg userinfo_encrypted_response_enc ].each do |column| auth_value_method :"oauth_applications_#{column}_column", column end - auth_value_method :oauth_grants_nonce_column, :nonce - auth_value_method :oauth_grants_acr_column, :acr - auth_value_method :oauth_grants_nonce_column, :nonce - auth_value_method :oauth_grants_acr_column, :acr + %i[nonce acr claims_locales claims].each do |column| + auth_value_method :"oauth_grants_#{column}_column", column + end auth_value_method :oauth_jwt_subject_type, "public" # fallback subject type: public, pairwise auth_value_method :oauth_jwt_subject_secret, nil # salt for pairwise generation translatable_method :oauth_invalid_scope_message, "The Access Token expired" auth_value_method :oauth_prompt_login_cookie_key, "_rodauth_oauth_prompt_login" auth_value_method :oauth_prompt_login_cookie_options, {}.freeze auth_value_method :oauth_prompt_login_interval, 5 * 60 * 60 # 5 minutes - # logout - auth_value_method :oauth_applications_post_logout_redirect_uri_column, :post_logout_redirect_uri - auth_value_method :use_rp_initiated_logout?, false - auth_value_methods( + :oauth_acr_values_supported, :get_oidc_account_last_login_at, + :oidc_authorize_on_prompt_none?, :get_oidc_param, :get_additional_param, :require_acr_value_phr, :require_acr_value_phrh, :require_acr_value, @@ -120,23 +118,45 @@ oauth_scopes.delete("openid") oidc_claims = { "sub" => claims["sub"] } - fill_with_account_claims(oidc_claims, account, oauth_scopes) - @oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => claims["client_id"]).first - if (algo = @oauth_application && @oauth_application[oauth_applications_userinfo_signed_response_alg_column]) + throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") unless @oauth_application + + oauth_grant = valid_oauth_grant_ds( + oauth_grants_oauth_application_id_column => @oauth_application[oauth_applications_id_column], + oauth_grants_account_id_column => account[account_id_column] + ).first + + claims_locales = oauth_grant[oauth_grants_claims_locales_column] if oauth_grant + + if (claims = oauth_grant[oauth_grants_claims_column]) + claims = JSON.parse(claims) + if (userinfo_essential_claims = claims["userinfo"]) + oauth_scopes |= userinfo_essential_claims.to_a + end + end + + # 5.4 - The Claims requested by the profile, email, address, and phone scope values are returned from the UserInfo Endpoint + fill_with_account_claims(oidc_claims, account, oauth_scopes, claims_locales) + + if (algo = @oauth_application[oauth_applications_userinfo_signed_response_alg_column]) params = { jwks: oauth_application_jwks(@oauth_application), encryption_algorithm: @oauth_application[oauth_applications_userinfo_encrypted_response_alg_column], encryption_method: @oauth_application[oauth_applications_userinfo_encrypted_response_enc_column] }.compact jwt = jwt_encode( - oidc_claims, + oidc_claims.merge( + # If signed, the UserInfo Response SHOULD contain the Claims iss (issuer) and aud (audience) as members. The iss value + # SHOULD be the OP's Issuer Identifier URL. The aud value SHOULD be or include the RP's Client ID value. + iss: oauth_jwt_issuer, + aud: @oauth_application[oauth_applications_client_id_column] + ), signing_algorithm: algo, **params ) jwt_response_success(jwt) else @@ -146,85 +166,10 @@ throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") end end - # /oidc-logout - auth_server_route(:oidc_logout) do |r| - next unless use_rp_initiated_logout? - - require_authorizable_account - before_oidc_logout_route - - # OpenID Providers MUST support the use of the HTTP GET and POST methods - r.on method: %i[get post] do - catch_error do - validate_oidc_logout_params - - # - # why this is done: - # - # we need to decode the id token in order to get the application, because, if the - # signing key is application-specific, we don't know how to verify the signature - # beforehand. Hence, we have to do it twice: decode-and-do-not-verify, initialize - # the @oauth_application, and then decode-and-verify. - # - claims = jwt_decode(param("id_token_hint"), verify_claims: false) - oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => claims["client_id"]).first - oauth_grant = db[oauth_grants_table] - .where( - oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column], - oauth_grants_account_id_column => account_id - ).first - - # check whether ID token belongs to currently logged-in user - redirect_response_error("invalid_request") unless oauth_grant && claims["sub"] == jwt_subject( - oauth_grant, oauth_application - ) - - # When an id_token_hint parameter is present, the OP MUST validate that it was the issuer of the ID Token. - redirect_response_error("invalid_request") unless claims && claims["iss"] == oauth_jwt_issuer - - # now let's logout from IdP - transaction do - before_logout - logout - after_logout - end - - if (post_logout_redirect_uri = param_or_nil("post_logout_redirect_uri")) - catch(:default_logout_redirect) do - oauth_application = db[oauth_applications_table].where(oauth_applications_client_id_column => claims["client_id"]).first - - throw(:default_logout_redirect) unless oauth_application - - post_logout_redirect_uris = oauth_application[oauth_applications_post_logout_redirect_uri_column].split(" ") - - throw(:default_logout_redirect) unless post_logout_redirect_uris.include?(post_logout_redirect_uri) - - if (state = param_or_nil("state")) - post_logout_redirect_uri = URI(post_logout_redirect_uri) - params = ["state=#{state}"] - params << post_logout_redirect_uri.query if post_logout_redirect_uri.query - post_logout_redirect_uri.query = params.join("&") - post_logout_redirect_uri = post_logout_redirect_uri.to_s - end - - redirect(post_logout_redirect_uri) - end - - end - - # regular logout procedure - set_notice_flash(logout_notice_flash) - redirect(logout_redirect) - end - - redirect_response_error("invalid_request") - end - end - def load_openid_configuration_route(alt_issuer = nil) request.on(".well-known/openid-configuration") do allow_cors(request) request.is do @@ -258,11 +203,15 @@ super end end def oauth_response_types_supported - super | %w[id_token none] + grant_types = oauth_grant_types_supported + oidc_response_types = %w[id_token none] + oidc_response_types |= ["code id_token"] if grant_types.include?("authorization_code") + oidc_response_types |= ["code token", "id_token token", "code id_token token"] if grant_types.include?("implicit") + super | oidc_response_types end def current_oauth_account subject_type = current_oauth_application[oauth_applications_subject_type_column] || oauth_jwt_subject_type @@ -282,31 +231,75 @@ super end end + def oauth_acr_values_supported + acr_values = [] + acr_values << "phrh" if features.include?(:webauthn_login) + acr_values << "phr" if respond_to?(:require_two_factor_authenticated) + acr_values + end + + def oidc_authorize_on_prompt_none?(_account) + false + end + def validate_authorize_params - return super unless (max_age = param_or_nil("max_age")) + if (max_age = param_or_nil("max_age")) - max_age = Integer(max_age) + max_age = Integer(max_age) - redirect_response_error("invalid_request") unless max_age.positive? + redirect_response_error("invalid_request") unless max_age.positive? - if Time.now - get_oidc_account_last_login_at(session_value) > max_age - # force user to re-login - clear_session - set_session_value(login_redirect_session_key, request.fullpath) - redirect require_login_redirect + if Time.now - get_oidc_account_last_login_at(session_value) > max_age + # force user to re-login + clear_session + set_session_value(login_redirect_session_key, request.fullpath) + redirect require_login_redirect + end end + if (claims = param_or_nil("claims")) + # The value is a JSON object listing the requested Claims. + claims = JSON.parse(claims) + + claims.each do |_, individual_claims| + redirect_response_error("invalid_request") unless individual_claims.is_a?(Hash) + + individual_claims.each do |_, claim| + redirect_response_error("invalid_request") unless claim.nil? || individual_claims.is_a?(Hash) + end + end + end + + sc = scopes + + if sc && sc.include?("offline_access") + + sc.delete("offline_access") + + # MUST ensure that the prompt parameter contains consent + # MUST ignore the offline_access request unless the Client + # is using a response_type value that would result in an + # Authorization Code + if param_or_nil("prompt") == "consent" && ( + (response_type = param_or_nil("response_type")) && response_type.split(" ").include?("code") + ) + request.params["access_type"] = "offline" + end + + request.params["scope"] = sc.join(" ") + end + super end def require_authorizable_account try_prompt super - try_acr_values + @acr = try_acr_values end def get_oidc_account_last_login_at(account_id) get_activity_timestamp(account_id, account_activity_last_activity_column) end @@ -346,26 +339,22 @@ def try_prompt return unless (prompt = param_or_nil("prompt")) case prompt when "none" + return unless request.get? + redirect_response_error("login_required") unless logged_in? require_account - if db[oauth_grants_table].where( - oauth_grants_account_id_column => account_id, - oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column], - oauth_grants_redirect_uri_column => redirect_uri, - oauth_grants_scopes_column => scopes.join(oauth_scope_separator), - oauth_grants_access_type_column => "online" - ).count.zero? - redirect_response_error("consent_required") - end + redirect_response_error("interaction_required") unless oidc_authorize_on_prompt_none?(account_from_session) request.env["REQUEST_METHOD"] = "POST" when "login" + return unless request.get? + if logged_in? && request.cookies[oauth_prompt_login_cookie_key] == "login" ::Rack::Utils.delete_cookie_header!(response.headers, oauth_prompt_login_cookie_key, oauth_prompt_login_cookie_options) return end @@ -378,22 +367,21 @@ login_cookie_opts[:expires] = convert_timestamp(Time.now + oauth_prompt_login_interval) # 15 minutes ::Rack::Utils.set_cookie_header!(response.headers, oauth_prompt_login_cookie_key, login_cookie_opts) redirect require_login_redirect when "consent" + return unless request.post? + require_account - if db[oauth_grants_table].where( - oauth_grants_account_id_column => account_id, - oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column], - oauth_grants_redirect_uri_column => redirect_uri, - oauth_grants_scopes_column => scopes.join(oauth_scope_separator), - oauth_grants_access_type_column => "online" - ).count.zero? - redirect_response_error("consent_required") - end + sc = scopes || [] + + redirect_response_error("consent_required") if sc.empty? + when "select-account" + return unless request.get? + # only works if select_account plugin is available require_select_account if respond_to?(:require_select_account) else redirect_response_error("invalid_request") end @@ -401,48 +389,72 @@ def try_acr_values return unless (acr_values = param_or_nil("acr_values")) acr_values.split(" ").each do |acr_value| + next unless oauth_acr_values_supported.include?(acr_value) + case acr_value - when "phr" then require_acr_value_phr - when "phrh" then require_acr_value_phrh + when "phr" + return acr_value if require_acr_value_phr + when "phrh" + return acr_value if require_acr_value_phrh else - require_acr_value(acr_value) + return acr_value if require_acr_value(acr_value) end end + + nil end def require_acr_value_phr - return unless respond_to?(:require_two_factor_authenticated) + return false unless respond_to?(:require_two_factor_authenticated) require_two_factor_authenticated + true end def require_acr_value_phrh + return false unless features.include?(:webauthn_login) + require_acr_value_phr && two_factor_login_type_match?("webauthn") end - def require_acr_value(_acr); end + def require_acr_value(_acr) + true + end def create_oauth_grant(create_params = {}) - if (nonce = param_or_nil("nonce")) - create_params[oauth_grants_nonce_column] = nonce - end - if (acr = param_or_nil("acr")) - create_params[oauth_grants_acr_column] = acr - end + create_params.replace(oidc_grant_params.merge(create_params)) super end + def create_oauth_grant_with_token(create_params = {}) + create_params[oauth_grants_type_column] = "hybrid" + create_params[oauth_grants_account_id_column] = account_id + create_params[oauth_grants_expires_in_column] = Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_access_token_expires_in) + authorization_code = create_oauth_grant(create_params) + access_token = if oauth_jwt_access_tokens + _generate_jwt_access_token(create_params) + else + oauth_grant = valid_oauth_grant_ds.where(oauth_grants_code_column => authorization_code).first + _generate_access_token(oauth_grant) + end + + { + "code" => authorization_code, + **json_access_token_payload(oauth_grants_token_column => access_token) + } + end + def create_token(*) oauth_grant = super generate_id_token(oauth_grant) oauth_grant end - def generate_id_token(oauth_grant) + def generate_id_token(oauth_grant, include_claims = false) oauth_scopes = oauth_grant[oauth_grants_scopes_column].split(oauth_scope_separator) return unless oauth_scopes.include?("openid") id_token_claims = jwt_claims(oauth_grant) @@ -459,12 +471,23 @@ # this should never happen! # a newly minted oauth token from a grant should have been assigned to an account # who just authorized its generation. return unless account - fill_with_account_claims(id_token_claims, account, oauth_scopes) + if (claims = oauth_grant[oauth_grants_claims_column]) + claims = JSON.parse(claims) + if (id_token_essential_claims = claims["id_token"]) + oauth_scopes |= id_token_essential_claims.to_a + include_claims = true + end + end + + # 5.4 - However, when no Access Token is issued (which is the case for the response_type value id_token), + # the resulting Claims are returned in the ID Token. + fill_with_account_claims(id_token_claims, account, oauth_scopes, param_or_nil("claims_locales")) if include_claims + params = { jwks: oauth_application_jwks(oauth_application), signing_algorithm: ( oauth_application[oauth_applications_id_token_signed_response_alg_column] || oauth_jwt_keys.keys.first @@ -475,30 +498,48 @@ oauth_grant[:id_token] = jwt_encode(id_token_claims, **params) end # aka fill_with_standard_claims - def fill_with_account_claims(claims, account, scopes) + def fill_with_account_claims(claims, account, scopes, claims_locales) + additional_claims_info = {} + scopes_by_claim = scopes.each_with_object({}) do |scope, by_oidc| next if scope == "openid" - oidc, param = scope.split(".", 2) + if scope.is_a?(Array) + # essential claims + param, additional_info = scope + param = param.to_sym + + oidc, = OIDC_SCOPES_MAP.find do |_, oidc_scopes| + oidc_scopes.include?(param) + end || param.to_s + + param = nil if oidc == param.to_s + + additional_claims_info[param] = additional_info + else + + oidc, param = scope.split(".", 2) + + param = param.to_sym if param + end + by_oidc[oidc] ||= [] by_oidc[oidc] << param.to_sym if param end oidc_scopes, additional_scopes = scopes_by_claim.keys.partition { |key| OIDC_SCOPES_MAP.key?(key) } - if (claims_locales = param_or_nil("claims_locales")) - claims_locales = claims_locales.split(" ").map(&:to_sym) - end + claims_locales = claims_locales.split(" ").map(&:to_sym) if claims_locales unless oidc_scopes.empty? if respond_to?(:get_oidc_param) - get_oidc_param = proxy_get_param(:get_oidc_param, claims, claims_locales) + get_oidc_param = proxy_get_param(:get_oidc_param, claims, claims_locales, additional_claims_info) oidc_scopes.each do |scope| scope_claims = claims params = scopes_by_claim[scope] params = params.empty? ? OIDC_SCOPES_MAP[scope] : (OIDC_SCOPES_MAP[scope] & params) @@ -515,37 +556,50 @@ end return if additional_scopes.empty? if respond_to?(:get_additional_param) - get_additional_param = proxy_get_param(:get_additional_param, claims, claims_locales) + get_additional_param = proxy_get_param(:get_additional_param, claims, claims_locales, additional_claims_info) additional_scopes.each do |scope| get_additional_param[account, scope.to_sym] end else warn "`get_additional_param(account, claim)` must be implemented to use oidc scopes." end end - def proxy_get_param(get_param_func, claims, claims_locales) + def proxy_get_param(get_param_func, claims, claims_locales, additional_claims_info) meth = method(get_param_func) if meth.arity == 2 - ->(account, param, cl = claims) { cl[param] = meth[account, param] } + lambda do |account, param, cl = claims| + additional_info = additional_claims_info[param] || EMPTY_HASH + value = additional_info["value"] || meth[account, param] + value = nil if additional_info["values"] && additional_info["values"].include?(value) + cl[param] = value if value + end elsif claims_locales.nil? - ->(account, param, cl = claims) { cl[param] = meth[account, param, nil] } + lambda do |account, param, cl = claims| + additional_info = additional_claims_info[param] || EMPTY_HASH + value = additional_info["value"] || meth[account, param, nil] + value = nil if additional_info["values"] && additional_info["values"].include?(value) + cl[param] = value if value + end else lambda do |account, param, cl = claims| claims_values = claims_locales.map do |locale| - meth[account, param, locale] - end + additional_info = additional_claims_info[param] || EMPTY_HASH + value = additional_info["value"] || meth[account, param, locale] + value = nil if additional_info["values"] && additional_info["values"].include?(value) + value + end.compact if claims_values.uniq.size == 1 cl[param] = claims_values.first else claims_locales.zip(claims_values).each do |locale, value| - cl["#{param}##{locale}"] = value + cl["#{param}##{locale}"] = value if value end end end end end @@ -578,72 +632,76 @@ def supports_token_response_type? features.include?(:oauth_implicit_grant) end def do_authorize(response_params = {}, response_mode = param_or_nil("response_mode")) - case param("response_type") + response_type = param("response_type") + case response_type when "id_token" - response_params.replace(_do_authorize_id_token) + grant_params = oidc_grant_params + generate_id_token(grant_params, true) + response_params.replace("id_token" => grant_params[:id_token]) when "code token" redirect_response_error("invalid_request") unless supports_token_response_type? - response_params.replace(_do_authorize_code.merge(_do_authorize_token)) + response_params.replace(create_oauth_grant_with_token) when "code id_token" - response_params.replace(_do_authorize_code.merge(_do_authorize_id_token)) + params = _do_authorize_code + oauth_grant = valid_oauth_grant_ds.where(oauth_grants_code_column => params["code"]).first + generate_id_token(oauth_grant) + response_params.replace( + "id_token" => oauth_grant[:id_token], + "code" => params["code"] + ) when "id_token token" redirect_response_error("invalid_request") unless supports_token_response_type? - response_params.replace(_do_authorize_id_token.merge(_do_authorize_token)) + oauth_grant = _do_authorize_token(oauth_grants_type_column => "hybrid") + generate_id_token(oauth_grant) + + response_params.replace(json_access_token_payload(oauth_grant)) when "code id_token token" redirect_response_error("invalid_request") unless supports_token_response_type? - response_params.replace(_do_authorize_code.merge(_do_authorize_id_token).merge(_do_authorize_token)) + params = create_oauth_grant_with_token + oauth_grant = valid_oauth_grant_ds.where(oauth_grants_code_column => params["code"]).first + generate_id_token(oauth_grant) + + response_params.replace(params.merge("id_token" => oauth_grant[:id_token])) when "none" response_mode ||= "none" end response_mode ||= "fragment" unless response_params.empty? super(response_params, response_mode) end - def _do_authorize_id_token + def oidc_grant_params grant_params = { oauth_grants_account_id_column => account_id, oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column], - oauth_grants_scopes_column => scopes.join(" ") + oauth_grants_scopes_column => scopes.join(oauth_scope_separator) } if (nonce = param_or_nil("nonce")) grant_params[oauth_grants_nonce_column] = nonce end - if (acr = param_or_nil("acr")) - grant_params[oauth_grants_acr_column] = acr + grant_params[oauth_grants_acr_column] = @acr if @acr + if (claims_locales = param_or_nil("claims_locales")) + grant_params[oauth_grants_claims_locales_column] = claims_locales end - oauth_grant = generate_token(grant_params, false) - generate_id_token(oauth_grant) - params = json_access_token_payload(oauth_grant) - params.delete("access_token") - params + if (claims = param_or_nil("claims")) + grant_params[oauth_grants_claims_column] = claims + end + grant_params end def authorize_response(params, mode) redirect_url = URI.parse(redirect_uri) redirect(redirect_url.to_s) if mode == "none" super end - # Logout - - def validate_oidc_logout_params - redirect_response_error("invalid_request") unless param_or_nil("id_token_hint") - # check if valid token hint type - return unless (redirect_uri = param_or_nil("post_logout_redirect_uri")) - - return if check_valid_uri?(redirect_uri) - - redirect_response_error("invalid_request") - end - # Webfinger def json_webfinger_payload JSON.dump({ subject: param("resource"), @@ -671,28 +729,18 @@ end end scope_claims.unshift("auth_time") - response_types_supported = metadata[:response_types_supported] - - response_types_supported |= %w[none] - response_types_supported |= ["code id_token"] if metadata[:grant_types_supported].include?("authorization_code") - if metadata[:grant_types_supported].include?("implicit") - response_types_supported |= ["code token", "id_token token", "code id_token token"] - end - - alg_values, enc_values = oauth_jwt_jwe_keys.keys.transpose - metadata.merge( userinfo_endpoint: userinfo_url, - end_session_endpoint: (oidc_logout_url if use_rp_initiated_logout?), - response_types_supported: response_types_supported, subject_types_supported: %w[public pairwise], + acr_values_supported: oauth_acr_values_supported, + claims_parameter_supported: true, - id_token_signing_alg_values_supported: metadata[:token_endpoint_auth_signing_alg_values_supported], - id_token_encryption_alg_values_supported: Array(alg_values), - id_token_encryption_enc_values_supported: Array(enc_values), + id_token_signing_alg_values_supported: oauth_jwt_jws_algorithms_supported, + id_token_encryption_alg_values_supported: oauth_jwt_jwe_algorithms_supported, + id_token_encryption_enc_values_supported: oauth_jwt_jwe_encryption_methods_supported, userinfo_signing_alg_values_supported: oauth_jwt_jws_algorithms_supported, userinfo_encryption_alg_values_supported: oauth_jwt_jwe_algorithms_supported, userinfo_encryption_enc_values_supported: oauth_jwt_jwe_encryption_methods_supported,