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

- old
+ new

@@ -1,7 +1,9 @@ # frozen_string_literal: true +require "rodauth/oauth" + module Rodauth Feature.define(:oidc, :Oidc) do # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims OIDC_SCOPES_MAP = { "profile" => %i[name family_name given_name middle_name nickname preferred_username @@ -58,76 +60,77 @@ response_types_supported subject_types_supported id_token_signing_alg_values_supported ].freeze - depends :account_expiration, :oauth_jwt + depends :account_expiration, :oauth_jwt, :oauth_jwt_jwks, :oauth_authorization_code_grant - auth_value_method :oauth_application_default_scope, "openid" auth_value_method :oauth_application_scopes, %w[openid] - auth_value_method :oauth_applications_id_token_signed_response_alg_column, :id_token_signed_response_alg - auth_value_method :oauth_applications_id_token_encrypted_response_alg_column, :id_token_encrypted_response_alg - auth_value_method :oauth_applications_id_token_encrypted_response_enc_column, :id_token_encrypted_response_enc - auth_value_method :oauth_applications_userinfo_signed_response_alg_column, :userinfo_signed_response_alg - auth_value_method :oauth_applications_userinfo_encrypted_response_alg_column, :userinfo_encrypted_response_alg - auth_value_method :oauth_applications_userinfo_encrypted_response_enc_column, :userinfo_encrypted_response_enc + %i[ + subject_type application_type sector_identifier_uri + id_token_signed_response_alg id_token_encrypted_response_alg id_token_encrypted_response_enc + 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_tokens_nonce_column, :nonce - auth_value_method :oauth_tokens_acr_column, :acr + auth_value_method :oauth_grants_nonce_column, :nonce + auth_value_method :oauth_grants_acr_column, :acr - translatable_method :invalid_scope_message, "The Access Token expired" + 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 - auth_value_method :webfinger_relation, "http://openid.net/specs/connect/1.0/issuer" + 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( + :get_oidc_account_last_login_at, :get_oidc_param, :get_additional_param, :require_acr_value_phr, :require_acr_value_phrh, - :require_acr_value + :require_acr_value, + :json_webfinger_payload ) # /userinfo - route(:userinfo) do |r| - next unless is_authorization_server? - + auth_server_route(:userinfo) do |r| r.on method: %i[get post] do catch_error do - oauth_token = authorization_token + claims = authorization_token - throw_json_response_error(authorization_required_error_status, "invalid_token") unless oauth_token + throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") unless claims - oauth_scopes = oauth_token["scope"].split(" ") + oauth_scopes = claims["scope"].split(" ") - throw_json_response_error(authorization_required_error_status, "invalid_token") unless oauth_scopes.include?("openid") + throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") unless oauth_scopes.include?("openid") - account = db[accounts_table].where(account_id_column => oauth_token["sub"]).first + account = db[accounts_table].where(account_id_column => claims["sub"]).first - throw_json_response_error(authorization_required_error_status, "invalid_token") unless account + throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") unless account oauth_scopes.delete("openid") - oidc_claims = { "sub" => oauth_token["sub"] } + 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 => oauth_token["client_id"]).first + @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]) params = { - jwks: oauth_application_jwks, + 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( @@ -139,20 +142,20 @@ else json_response_success(oidc_claims) end end - throw_json_response_error(authorization_required_error_status, "invalid_token") + throw_json_response_error(oauth_authorization_required_error_status, "invalid_token") end end # /oidc-logout - route(:oidc_logout) do |r| + auth_server_route(:oidc_logout) do |r| next unless use_rp_initiated_logout? - before_oidc_logout_route 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 @@ -163,32 +166,36 @@ # 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. # - oauth_token = jwt_decode(param("id_token_hint"), verify_claims: false) - oauth_application_id = oauth_token["client_id"] + 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_token["sub"] == jwt_subject( - oauth_tokens_account_id_column => account_id, - oauth_tokens_oauth_application_id_column => oauth_application_id + 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 oauth_token && oauth_token["iss"] == issuer + 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 => oauth_token["client_id"]).first + 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(" ") @@ -214,38 +221,33 @@ redirect_response_error("invalid_request") end end - def openid_configuration(alt_issuer = nil) + def load_openid_configuration_route(alt_issuer = nil) request.on(".well-known/openid-configuration") do allow_cors(request) - request.get do - json_response_success(openid_configuration_body(alt_issuer), cache: true) + request.is do + request.get do + json_response_success(openid_configuration_body(alt_issuer), cache: true) + end end end end - def webfinger + def load_webfinger_route request.on(".well-known/webfinger") do request.get do resource = param_or_nil("resource") throw_json_response_error(400, "invalid_request") unless resource response.status = 200 response["Content-Type"] ||= "application/jrd+json" - json_payload = JSON.dump({ - subject: resource, - links: [{ - rel: webfinger_relation, - href: authorization_server_url - }] - }) - return_response(json_payload) + return_response(json_webfinger_payload) end end end def check_csrf? @@ -255,10 +257,20 @@ else super end end + def oauth_response_types_supported + super | %w[id_token none] + end + + def current_oauth_account + subject_type = current_oauth_application[oauth_applications_subject_type_column] || oauth_jwt_subject_type + + return super unless subject_type == "pairwise" + end + private if defined?(::I18n) def before_authorize_route if (ui_locales = param_or_nil("ui_locales")) @@ -277,11 +289,11 @@ max_age = Integer(max_age) redirect_response_error("invalid_request") unless max_age.positive? - if Time.now - last_account_login_at > max_age + 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 @@ -293,10 +305,45 @@ try_prompt super try_acr_values end + def get_oidc_account_last_login_at(account_id) + get_activity_timestamp(account_id, account_activity_last_activity_column) + end + + def jwt_subject(oauth_grant, client_application = oauth_application) + subject_type = client_application[oauth_applications_subject_type_column] || oauth_jwt_subject_type + + case subject_type + when "public" + super + when "pairwise" + identifier_uri = client_application[oauth_applications_sector_identifier_uri_column] + + unless identifier_uri + identifier_uri = client_application[oauth_applications_redirect_uri_column] + identifier_uri = identifier_uri.split(" ") + # If the Client has not provided a value for sector_identifier_uri in Dynamic Client Registration + # [OpenID.Registration], the Sector Identifier used for pairwise identifier calculation is the host + # component of the registered redirect_uri. If there are multiple hostnames in the registered redirect_uris, + # the Client MUST register a sector_identifier_uri. + if identifier_uri.size > 1 + # return error message + end + identifier_uri = identifier_uri.first + end + + identifier_uri = URI(identifier_uri).host + + account_id = oauth_grant[oauth_grants_account_id_column] + Digest::SHA256.hexdigest("#{identifier_uri}#{account_id}#{oauth_jwt_subject_secret}") + else + raise StandardError, "unexpected subject (#{subject_type})" + end + end + # this executes before checking for a logged in account def try_prompt return unless (prompt = param_or_nil("prompt")) case prompt @@ -385,54 +432,50 @@ create_params[oauth_grants_acr_column] = acr end super end - def create_oauth_token_from_authorization_code(oauth_grant, create_params, *) - create_params[oauth_tokens_nonce_column] = oauth_grant[oauth_grants_nonce_column] if oauth_grant[oauth_grants_nonce_column] - create_params[oauth_tokens_acr_column] = oauth_grant[oauth_grants_acr_column] if oauth_grant[oauth_grants_acr_column] - - super + def create_token(*) + oauth_grant = super + generate_id_token(oauth_grant) + oauth_grant end - def create_oauth_token(*) - oauth_token = super - generate_id_token(oauth_token) - oauth_token - end + def generate_id_token(oauth_grant) + oauth_scopes = oauth_grant[oauth_grants_scopes_column].split(oauth_scope_separator) - def generate_id_token(oauth_token) - oauth_scopes = oauth_token[oauth_tokens_scopes_column].split(oauth_scope_separator) - return unless oauth_scopes.include?("openid") - id_token_claims = jwt_claims(oauth_token) + id_token_claims = jwt_claims(oauth_grant) - id_token_claims[:nonce] = oauth_token[oauth_tokens_nonce_column] if oauth_token[oauth_tokens_nonce_column] + id_token_claims[:nonce] = oauth_grant[oauth_grants_nonce_column] if oauth_grant[oauth_grants_nonce_column] - id_token_claims[:acr] = oauth_token[oauth_tokens_acr_column] if oauth_token[oauth_tokens_acr_column] + id_token_claims[:acr] = oauth_grant[oauth_grants_acr_column] if oauth_grant[oauth_grants_acr_column] # Time when the End-User authentication occurred. - id_token_claims[:auth_time] = last_account_login_at.to_i + id_token_claims[:auth_time] = get_oidc_account_last_login_at(oauth_grant[oauth_grants_account_id_column]).to_i - account = db[accounts_table].where(account_id_column => oauth_token[oauth_tokens_account_id_column]).first + account = db[accounts_table].where(account_id_column => oauth_grant[oauth_grants_account_id_column]).first # 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) params = { - jwks: oauth_application_jwks, - signing_algorithm: oauth_application[oauth_applications_id_token_signed_response_alg_column] || oauth_jwt_algorithm, + jwks: oauth_application_jwks(oauth_application), + signing_algorithm: ( + oauth_application[oauth_applications_id_token_signed_response_alg_column] || + oauth_jwt_keys.keys.first + ), encryption_algorithm: oauth_application[oauth_applications_id_token_encrypted_response_alg_column], encryption_method: oauth_application[oauth_applications_id_token_encrypted_response_enc_column] }.compact - oauth_token[:id_token] = jwt_encode(id_token_claims, **params) + oauth_grant[:id_token] = jwt_encode(id_token_claims, **params) end # aka fill_with_standard_claims def fill_with_account_claims(claims, account, scopes) scopes_by_claim = scopes.each_with_object({}) do |scope, by_oidc| @@ -505,70 +548,90 @@ end end end end - def json_access_token_payload(oauth_token) + def json_access_token_payload(oauth_grant) payload = super - payload["id_token"] = oauth_token[:id_token] if oauth_token[:id_token] + payload["id_token"] = oauth_grant[:id_token] if oauth_grant[:id_token] payload end # Authorize def check_valid_response_type? case param_or_nil("response_type") - when "none", "id_token", - "code token", "code id_token", "id_token token", "code id_token token" # multiple + when "none", "id_token", "code id_token" # multiple true + when "code token", "id_token token", "code id_token token" + supports_token_response_type? else super end end - def do_authorize(response_params = {}, response_mode = param_or_nil("response_mode")) - return super unless use_oauth_implicit_grant_type? + def supported_response_mode?(response_mode, *) + return super unless response_mode == "none" + param("response_type") == "none" + end + + 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") when "id_token" response_params.replace(_do_authorize_id_token) when "code token" - redirect_response_error("invalid_request") unless use_oauth_implicit_grant_type? + redirect_response_error("invalid_request") unless supports_token_response_type? response_params.replace(_do_authorize_code.merge(_do_authorize_token)) when "code id_token" response_params.replace(_do_authorize_code.merge(_do_authorize_id_token)) 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)) 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)) + when "none" + response_mode ||= "none" end response_mode ||= "fragment" unless response_params.empty? super(response_params, response_mode) end def _do_authorize_id_token - create_params = { - oauth_tokens_account_id_column => account_id, - oauth_tokens_oauth_application_id_column => oauth_application[oauth_applications_id_column], - oauth_tokens_scopes_column => scopes + 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(" ") } if (nonce = param_or_nil("nonce")) - create_params[oauth_grants_nonce_column] = nonce + grant_params[oauth_grants_nonce_column] = nonce end if (acr = param_or_nil("acr")) - create_params[oauth_grants_acr_column] = acr + grant_params[oauth_grants_acr_column] = acr end - oauth_token = generate_oauth_token(create_params, false) - generate_id_token(oauth_token) - params = json_access_token_payload(oauth_token) + oauth_grant = generate_token(grant_params, false) + generate_id_token(oauth_grant) + params = json_access_token_payload(oauth_grant) params.delete("access_token") 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 @@ -577,10 +640,22 @@ return if check_valid_uri?(redirect_uri) redirect_response_error("invalid_request") end + # Webfinger + + def json_webfinger_payload + JSON.dump({ + subject: param("resource"), + links: [{ + rel: "http://openid.net/specs/connect/1.0/issuer", + href: authorization_server_url + }] + }) + end + # Metadata def openid_configuration_body(path = nil) metadata = oauth_server_metadata_body(path).select do |k, _| VALID_METADATA_KEYS.include?(k) @@ -598,31 +673,35 @@ 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 += ["none", "id_token", "code token", "code id_token", "id_token token", "code id_token token"] + 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: [oauth_jwt_subject_type], + subject_types_supported: %w[public pairwise], id_token_signing_alg_values_supported: metadata[:token_endpoint_auth_signing_alg_values_supported], - id_token_encryption_alg_values_supported: [oauth_jwt_jwe_algorithm].compact, - id_token_encryption_enc_values_supported: [oauth_jwt_jwe_encryption_method].compact, + id_token_encryption_alg_values_supported: Array(alg_values), + id_token_encryption_enc_values_supported: Array(enc_values), - userinfo_signing_alg_values_supported: [], - userinfo_encryption_alg_values_supported: [], - userinfo_encryption_enc_values_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, - request_object_signing_alg_values_supported: [], - request_object_encryption_alg_values_supported: [], - request_object_encryption_enc_values_supported: [], + request_object_signing_alg_values_supported: oauth_jwt_jws_algorithms_supported, + request_object_encryption_alg_values_supported: oauth_jwt_jwe_algorithms_supported, + request_object_encryption_enc_values_supported: oauth_jwt_jwe_encryption_methods_supported, # These Claim Types are described in Section 5.6 of OpenID Connect Core 1.0 [OpenID.Core]. # Values defined by this specification are normal, aggregated, and distributed. # If omitted, the implementation supports only normal Claims. claim_types_supported: %w[normal], @@ -641,8 +720,22 @@ response["Access-Control-Allow-Origin"] = "*" response["Access-Control-Allow-Methods"] = "GET, OPTIONS" response["Access-Control-Max-Age"] = "3600" response.status = 200 return_response + end + + def jwt_response_success(jwt, cache = false) + response.status = 200 + response["Content-Type"] ||= "application/jwt" + if cache + # defaulting to 1-day for everyone, for now at least + max_age = 60 * 60 * 24 + response["Cache-Control"] = "private, max-age=#{max_age}" + else + response["Cache-Control"] = "no-store" + response["Pragma"] = "no-cache" + end + return_response(jwt) end end end