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