lib/rodauth/features/oauth.rb in rodauth-oauth-0.2.0 vs lib/rodauth/features/oauth.rb in rodauth-oauth-0.3.0

- old
+ new

@@ -44,10 +44,12 @@ using(SuffixExtensions) end SCOPES = %w[profile.read].freeze + SERVER_METADATA = OAuth::TtlStore.new + before "authorize" after "authorize" before "token" @@ -73,10 +75,11 @@ auth_value_method :json_response_content_type, "application/json" auth_value_method :oauth_grant_expires_in, 60 * 5 # 5 minutes auth_value_method :oauth_token_expires_in, 60 * 60 # 60 minutes + auth_value_method :oauth_refresh_token_expires_in, 60 * 60 * 24 * 360 # 1 year auth_value_method :use_oauth_implicit_grant_type?, false auth_value_method :use_oauth_pkce?, true auth_value_method :use_oauth_access_type?, true auth_value_method :oauth_require_pkce, false @@ -147,13 +150,15 @@ homepage_url redirect_uri ].each do |column| auth_value_method :"oauth_applications_#{column}_column", column end + # Feature options auth_value_method :oauth_application_default_scope, SCOPES.first auth_value_method :oauth_application_scopes, SCOPES auth_value_method :oauth_token_type, "bearer" + auth_value_method :oauth_refresh_token_protection_policy, "none" # can be: none, sender_constrained, rotation auth_value_method :invalid_client_message, "Invalid client" auth_value_method :invalid_grant_type_message, "Invalid grant type" auth_value_method :invalid_grant_message, "Invalid grant" auth_value_method :invalid_scope_message, "Invalid scope" @@ -198,12 +203,189 @@ auth_value_methods(:only_json?) auth_value_method :json_request_regexp, %r{\bapplication/(?:vnd\.api\+)?json\b}i - SERVER_METADATA = OAuth::TtlStore.new + # /token + route(:token) do |r| + next unless is_authorization_server? + before_token_route + require_oauth_application + + r.post do + catch_error do + validate_oauth_token_params + + oauth_token = nil + transaction do + before_token + oauth_token = create_oauth_token + end + + json_response_success(json_access_token_payload(oauth_token)) + end + + throw_json_response_error(invalid_oauth_response_status, "invalid_request") + end + end + + # /introspect + route(:introspect) do |r| + next unless is_authorization_server? + + before_introspect_route + + r.post do + catch_error do + validate_oauth_introspect_params + + before_introspect + oauth_token = case param("token_type_hint") + when "access_token" + oauth_token_by_token(param("token")) + when "refresh_token" + oauth_token_by_refresh_token(param("token")) + else + oauth_token_by_token(param("token")) || oauth_token_by_refresh_token(param("token")) + end + + if oauth_application + redirect_response_error("invalid_request") if oauth_token && !token_from_application?(oauth_token, oauth_application) + elsif oauth_token + @oauth_application = db[oauth_applications_table].where(oauth_applications_id_column => + oauth_token[oauth_tokens_oauth_application_id_column]).first + end + + json_response_success(json_token_introspect_payload(oauth_token)) + end + + throw_json_response_error(invalid_oauth_response_status, "invalid_request") + end + end + + # /revoke + route(:revoke) do |r| + next unless is_authorization_server? + + before_revoke_route + require_oauth_application + + r.post do + catch_error do + validate_oauth_revoke_params + + oauth_token = nil + transaction do + before_revoke + oauth_token = revoke_oauth_token + after_revoke + end + + if accepts_json? + json_response_success \ + "token" => oauth_token[oauth_tokens_token_column], + "refresh_token" => oauth_token[oauth_tokens_refresh_token_column], + "revoked_at" => convert_timestamp(oauth_token[oauth_tokens_revoked_at_column]) + else + set_notice_flash revoke_oauth_token_notice_flash + redirect request.referer || "/" + end + end + + redirect_response_error("invalid_request", request.referer || "/") + end + end + + # /authorize + route(:authorize) do |r| + next unless is_authorization_server? + + before_authorize_route + require_authorizable_account + + validate_oauth_grant_params + try_approval_prompt if use_oauth_access_type? && request.get? + + r.get do + authorize_view + end + + r.post do + redirect_url = URI.parse(redirect_uri) + + transaction do + before_authorize + do_authorize(redirect_url) + end + redirect(redirect_url.to_s) + end + end + + def oauth_server_metadata(issuer = nil) + request.on(".well-known") do + request.on("oauth-authorization-server") do + request.get do + json_response_success(oauth_server_metadata_body(issuer), true) + end + end + end + end + + # /oauth-applications routes + def oauth_applications + request.on(oauth_applications_path) do + require_account + + request.get "new" do + new_oauth_application_view + end + + request.on(oauth_applications_id_pattern) do |id| + oauth_application = db[oauth_applications_table].where(oauth_applications_id_column => id).first + next unless oauth_application + + scope.instance_variable_set(:@oauth_application, oauth_application) + + request.is do + request.get do + oauth_application_view + end + end + + request.on(oauth_tokens_path) do + oauth_tokens = db[oauth_tokens_table].where(oauth_tokens_oauth_application_id_column => id) + scope.instance_variable_set(:@oauth_tokens, oauth_tokens) + request.get do + oauth_tokens_view + end + end + end + + request.get do + scope.instance_variable_set(:@oauth_applications, db[oauth_applications_table]) + oauth_applications_view + end + + request.post do + catch_error do + validate_oauth_application_params + + transaction do + before_create_oauth_application + id = create_oauth_application + after_create_oauth_application + set_notice_flash create_oauth_application_notice_flash + redirect "#{request.path}/#{id}" + end + end + set_error_flash create_oauth_application_error_flash + new_oauth_application_view + end + end + end + def check_csrf? case request.path when token_path, introspect_path false when revoke_path @@ -322,73 +504,10 @@ end authorization_required unless scopes.any? { |scope| token_scopes.include?(scope) } end - # /oauth-applications routes - def oauth_applications - request.on(oauth_applications_path) do - require_account - - request.get "new" do - new_oauth_application_view - end - - request.on(oauth_applications_id_pattern) do |id| - oauth_application = db[oauth_applications_table].where(oauth_applications_id_column => id).first - next unless oauth_application - - scope.instance_variable_set(:@oauth_application, oauth_application) - - request.is do - request.get do - oauth_application_view - end - end - - request.on(oauth_tokens_path) do - oauth_tokens = db[oauth_tokens_table].where(oauth_tokens_oauth_application_id_column => id) - scope.instance_variable_set(:@oauth_tokens, oauth_tokens) - request.get do - oauth_tokens_view - end - end - end - - request.get do - scope.instance_variable_set(:@oauth_applications, db[oauth_applications_table]) - oauth_applications_view - end - - request.post do - catch_error do - validate_oauth_application_params - - transaction do - before_create_oauth_application - id = create_oauth_application - after_create_oauth_application - set_notice_flash create_oauth_application_notice_flash - redirect "#{request.path}/#{id}" - end - end - set_error_flash create_oauth_application_error_flash - new_oauth_application_view - end - end - end - - def oauth_server_metadata(issuer = nil) - request.on(".well-known") do - request.on("oauth-authorization-server") do - request.get do - json_response_success(oauth_server_metadata_body(issuer)) - end - end - end - end - def post_configure super self.class.__send__(:include, Rodauth::OAuth::ExtendDatabase(db)) # Check whether we can reutilize db entries for the same account / application pair @@ -399,10 +518,14 @@ end end self.class.send(:define_method, :__one_oauth_token_per_account) { one_oauth_token_per_account } end + def use_date_arithmetic? + true + end + private def rescue_from_uniqueness_error(&block) retries = oauth_unique_id_generation_retries begin @@ -444,13 +567,13 @@ authorization_required unless response.code.to_i == 200 # time-to-live ttl = if response.key?("cache-control") cache_control = response["cache-control"] - cache_control[/max-age=(\d+)/, 1] + cache_control[/max-age=(\d+)/, 1].to_i elsif response.key?("expires") - DateTime.httpdate(response["expires"]).utc.to_i - Time.now.utc.to_i + Time.parse(response["expires"]).to_i - Time.now.to_i end [JSON.parse(response.body, symbolize_names: true), ttl] end end @@ -514,11 +637,11 @@ def secret_hash(secret) password_hash(secret) end def oauth_unique_id_generator - SecureRandom.hex(32) + SecureRandom.urlsafe_base64(32) end def generate_token_hash(token) Base64.urlsafe_encode64(Digest::SHA256.digest(token)) end @@ -535,11 +658,11 @@ end end def generate_oauth_token(params = {}, should_generate_refresh_token = true) create_params = { - oauth_grants_expires_in_column => Time.now + oauth_token_expires_in + oauth_grants_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_token_expires_in) }.merge(params) rescue_from_uniqueness_error do token = oauth_unique_id_generator @@ -595,30 +718,41 @@ end __insert_and_return__(ds, oauth_tokens_id_column, params) end end - def oauth_token_by_token(token, dataset = db[oauth_tokens_table]) + def oauth_token_by_token(token) + ds = db[oauth_tokens_table] + ds = if oauth_tokens_token_hash_column - dataset.where(oauth_tokens_token_hash_column => generate_token_hash(token)) + ds.where(oauth_tokens_token_hash_column => generate_token_hash(token)) else - dataset.where(oauth_tokens_token_column => token) + ds.where(oauth_tokens_token_column => token) end ds.where(Sequel[oauth_tokens_expires_in_column] >= Sequel::CURRENT_TIMESTAMP) .where(oauth_tokens_revoked_at_column => nil).first end - def oauth_token_by_refresh_token(token, dataset = db[oauth_tokens_table]) + def oauth_token_by_refresh_token(token, revoked: false) + ds = db[oauth_tokens_table] + # + # filter expired refresh tokens out. + # an expired refresh token is a token whose access token expired for a period longer than the + # refresh token expiration period. + # + ds = ds.where(Sequel.date_add(oauth_tokens_expires_in_column, seconds: oauth_refresh_token_expires_in) >= Sequel::CURRENT_TIMESTAMP) + ds = if oauth_tokens_refresh_token_hash_column - dataset.where(oauth_tokens_refresh_token_hash_column => generate_token_hash(token)) + ds.where(oauth_tokens_refresh_token_hash_column => generate_token_hash(token)) else - dataset.where(oauth_tokens_refresh_token_column => token) + ds.where(oauth_tokens_refresh_token_column => token) end - ds.where(Sequel[oauth_tokens_expires_in_column] >= Sequel::CURRENT_TIMESTAMP) - .where(oauth_tokens_revoked_at_column => nil).first + ds = ds.where(oauth_tokens_revoked_at_column => nil) unless revoked + + ds.first end def json_access_token_payload(oauth_token) payload = { "access_token" => oauth_token[oauth_tokens_token_column], @@ -729,20 +863,19 @@ oauth_grants_scopes_column => scopes.join(oauth_scope_separator), oauth_grants_access_type_column => "online" ).count.zero? # if there's a previous oauth grant for the params combo, it means that this user has approved before. - request.env["REQUEST_METHOD"] = "POST" end def create_oauth_grant(create_params = {}) create_params.merge!( 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_expires_in_column => Time.now + oauth_grant_expires_in, + oauth_grants_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_grant_expires_in), oauth_grants_scopes_column => scopes.join(oauth_scope_separator) ) # Access Type flow if use_oauth_access_type? && (access_type = param_or_nil("access_type")) @@ -844,18 +977,34 @@ oauth_tokens_oauth_grant_id_column => oauth_grant[oauth_grants_id_column], oauth_tokens_scopes_column => oauth_grant[oauth_grants_scopes_column] } create_oauth_token_from_authorization_code(oauth_grant, create_params) when "refresh_token" - # fetch oauth token - oauth_token = oauth_token_by_refresh_token(param("refresh_token")) + # fetch potentially revoked oauth token + oauth_token = oauth_token_by_refresh_token(param("refresh_token"), revoked: true) - redirect_response_error("invalid_grant") unless oauth_token + if !oauth_token + redirect_response_error("invalid_grant") + elsif oauth_token[oauth_tokens_revoked_at_column] + if oauth_refresh_token_protection_policy == "rotation" + # https://tools.ietf.org/html/draft-ietf-oauth-v2-1-00#section-6.1 + # + # If a refresh token is compromised and subsequently used by both the attacker and the legitimate + # client, one of them will present an invalidated refresh token, which will inform the authorization + # server of the breach. The authorization server cannot determine which party submitted the invalid + # refresh token, but it will revoke the active refresh token. This stops the attack at the cost of + # forcing the legitimate client to obtain a fresh authorization grant. + db[oauth_tokens_table].where(oauth_tokens_oauth_token_id_column => oauth_token[oauth_tokens_id_column]) + .update(oauth_tokens_revoked_at_column => Sequel::CURRENT_TIMESTAMP) + end + redirect_response_error("invalid_grant") + end + update_params = { oauth_tokens_oauth_application_id_column => oauth_token[oauth_grants_oauth_application_id_column], - oauth_tokens_expires_in_column => Time.now + oauth_token_expires_in + oauth_tokens_expires_in_column => Sequel.date_add(Sequel::CURRENT_TIMESTAMP, seconds: oauth_token_expires_in) } create_oauth_token_from_token(oauth_token, update_params) end end @@ -883,21 +1032,38 @@ def create_oauth_token_from_token(oauth_token, update_params) redirect_response_error("invalid_grant") unless token_from_application?(oauth_token, oauth_application) rescue_from_uniqueness_error do + oauth_tokens_ds = db[oauth_tokens_table] token = oauth_unique_id_generator if oauth_tokens_token_hash_column update_params[oauth_tokens_token_hash_column] = generate_token_hash(token) else update_params[oauth_tokens_token_column] = token end - ds = db[oauth_tokens_table].where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column]) + oauth_token = if oauth_refresh_token_protection_policy == "rotation" + insert_params = { + **update_params, + oauth_tokens_oauth_token_id_column => oauth_token[oauth_tokens_id_column], + oauth_tokens_scopes_column => oauth_token[oauth_tokens_scopes_column] + } - oauth_token = __update_and_return__(ds, update_params) + # revoke the refresh token + oauth_tokens_ds.where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column]) + .update(oauth_tokens_revoked_at_column => Sequel::CURRENT_TIMESTAMP) + + insert_params[oauth_tokens_oauth_token_id_column] = oauth_token[oauth_tokens_id_column] + __insert_and_return__(oauth_tokens_ds, oauth_tokens_id_column, insert_params) + else + # includes none + ds = oauth_tokens_ds.where(oauth_tokens_id_column => oauth_token[oauth_tokens_id_column]) + __update_and_return__(ds, update_params) + end + oauth_token[oauth_tokens_token_column] = token oauth_token end end @@ -998,13 +1164,21 @@ redirect_url.query = query_params.join("&") redirect(redirect_url.to_s) end end - def json_response_success(body) + def json_response_success(body, cache = false) response.status = 200 response["Content-Type"] ||= json_response_content_type + 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 json_payload = _json_response_body(body) response.write(json_payload) request.halt end @@ -1128,15 +1302,16 @@ if use_oauth_implicit_grant_type? responses_supported << "token" response_modes_supported << "fragment" grant_types_supported << "implicit" end + { issuer: issuer, authorization_endpoint: authorize_url, token_endpoint: token_url, - registration_endpoint: "#{base_url}/#{oauth_applications_path}", + registration_endpoint: route_url(oauth_applications_path), scopes_supported: oauth_application_scopes, response_types_supported: responses_supported, response_modes_supported: response_modes_supported, grant_types_supported: grant_types_supported, token_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post], @@ -1148,124 +1323,8 @@ revocation_endpoint_auth_methods_supported: nil, # because it's client_secret_basic introspection_endpoint: introspect_url, introspection_endpoint_auth_methods_supported: %w[client_secret_basic], code_challenge_methods_supported: (use_oauth_pkce? ? oauth_pkce_challenge_method : nil) } - end - - # /token - route(:token) do |r| - next unless is_authorization_server? - - before_token_route - require_oauth_application - - r.post do - catch_error do - validate_oauth_token_params - - oauth_token = nil - transaction do - before_token - oauth_token = create_oauth_token - end - - json_response_success(json_access_token_payload(oauth_token)) - end - - throw_json_response_error(invalid_oauth_response_status, "invalid_request") - end - end - - # /introspect - route(:introspect) do |r| - next unless is_authorization_server? - - before_introspect_route - - r.post do - catch_error do - validate_oauth_introspect_params - - before_introspect - oauth_token = case param("token_type_hint") - when "access_token" - oauth_token_by_token(param("token")) - when "refresh_token" - oauth_token_by_refresh_token(param("token")) - else - oauth_token_by_token(param("token")) || oauth_token_by_refresh_token(param("token")) - end - - if oauth_application - redirect_response_error("invalid_request") if oauth_token && !token_from_application?(oauth_token, oauth_application) - elsif oauth_token - @oauth_application = db[oauth_applications_table].where(oauth_applications_id_column => - oauth_token[oauth_tokens_oauth_application_id_column]).first - end - - json_response_success(json_token_introspect_payload(oauth_token)) - end - - throw_json_response_error(invalid_oauth_response_status, "invalid_request") - end - end - - # /revoke - route(:revoke) do |r| - next unless is_authorization_server? - - before_revoke_route - require_oauth_application - - r.post do - catch_error do - validate_oauth_revoke_params - - oauth_token = nil - transaction do - before_revoke - oauth_token = revoke_oauth_token - after_revoke - end - - if accepts_json? - json_response_success \ - "token" => oauth_token[oauth_tokens_token_column], - "refresh_token" => oauth_token[oauth_tokens_refresh_token_column], - "revoked_at" => oauth_token[oauth_tokens_revoked_at_column] - else - set_notice_flash revoke_oauth_token_notice_flash - redirect request.referer || "/" - end - end - - redirect_response_error("invalid_request", request.referer || "/") - end - end - - # /authorize - route(:authorize) do |r| - next unless is_authorization_server? - - before_authorize_route - require_authorizable_account - - validate_oauth_grant_params - try_approval_prompt if use_oauth_access_type? && request.get? - - r.get do - authorize_view - end - - r.post do - redirect_url = URI.parse(redirect_uri) - - transaction do - before_authorize - do_authorize(redirect_url) - end - redirect(redirect_url.to_s) - end end end end