Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,58 @@
# Changelog

## [Unreleased]

### Added
- Refresh token rotation with reuse detection behind `refresh_token.rotation_enabled` (default `false`): each refresh revokes the presented refresh token, and presenting an already-rotated/revoked refresh token revokes the whole token family (SEC-2)
- Paranoid mode behind `paranoid` (default `false`): unknown accounts and wrong passwords both return the generic `invalid_authentication` error with no lockable/confirmable details, preventing account enumeration (SEC-5)
- `error_response.verbose_account_state` (default `true`): set to `false` to omit the `lockable`/`confirmable` metadata blocks from error responses (SEC-7)
- `Devise::Api::Token#revoke!` and `#revoke_family!` helpers
- New `invalid_login` error (HTTP 400) returned instead of `invalid_email` when the model's `authentication_keys` do not include `:email`
- Lockable error responses now include the correctly spelled `failed_attempts` key alongside the deprecated `failed_attemps` (the misspelling will be removed in the next major release)
- `access_token`, `refresh_token` and `previous_refresh_token` are added to the host app's `filter_parameters`, and the token model filters them from `#inspect` output (GH-51)

### Changed
- **Breaking-ish:** `POST /<scope>/tokens/refresh` with an unknown refresh token now returns `invalid_refresh_token` (HTTP 400) instead of `invalid_token` (HTTP 401)
- The install generator's migration now creates **unique** indexes on `access_token` and `refresh_token`; token creation rescues `ActiveRecord::RecordNotUnique` and retries with a fresh token (SEC-4). Existing installs should add a migration:
```ruby
remove_index :devise_api_tokens, :access_token
remove_index :devise_api_tokens, :refresh_token
add_index :devise_api_tokens, :access_token, unique: true
add_index :devise_api_tokens, :refresh_token, unique: true
```
- `current_devise_api_refresh_token` is now memoized in the shared controller helpers (the duplicate controller-level override was removed)
- Internal time handling standardized on `Time.current`

### Removed
- Vestigial RBS stub (`sig/devise/api.rbs`)

## [0.2.0] - 2024-09-27

- Resource lookup uses the model's `authentication_keys` instead of hardcoding `email` (#46)
- Fixed nil memoization of `current_devise_api_token` / `current_devise_api_refresh_token` (#48)
- Fixed the translation key for the unconfirmed signup message (#49)

## [0.1.3] - 2023-08-08

- Fixed `AbstractController::DoubleRenderError` on refresh (#29)
- Allowed defining extra fields for sign up via `sign_up.extra_fields` (#36, #38)
- Disabled parameter wrapping in `TokensController` (#42)

## [0.1.2] - 2023-05-30

- Added `sign_up.enabled` option to disable the sign up endpoint (#15)
- Fixed refresh behavior (#14)
- Fixed undefined variable error in the controller helper (#25)
- Migration template respects the configured primary/foreign key types (#23)

## [0.1.1] - 2023-01-14

- Fixed invalid strategy error (#2)

## [0.1.0] - 2023-01-14

- First public release: `:api` Devise module with token sign up / sign in / refresh / revoke / info endpoints (#1)

## [0.0.0] - 2023-01-09

- Initial release
22 changes: 18 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,15 @@ Devise.setup do |config|
api.refresh_token.expires_in = 1.week
api.refresh_token.generator = ->(_resource_owner) { Devise.friendly_token(60) }
api.refresh_token.expires_in_infinite = ->(_resource_owner) { false }
api.refresh_token.rotation_enabled = false # when true, each refresh revokes the presented refresh token and a replayed one revokes the whole token family (recommended)

# Sign up
api.sign_up.enabled = true
api.sign_up.extra_fields = []
api.sign_up.extra_fields = [] # WARNING: listed fields are writable at sign up AND echoed in token/info responses - never list privileged fields like :role or :admin

# Error responses
api.error_response.verbose_account_state = true # when false, lockable/confirmable details are omitted from error responses
api.paranoid = false # when true, unknown accounts and wrong passwords return the same generic invalid_authentication error (prevents account enumeration)

# Authorization
api.authorization.key = 'Authorization'
Expand Down Expand Up @@ -125,7 +130,7 @@ end

## Routes

You can configure the tokens routes with the orginally `devise_for` method. For example:
You can configure the tokens routes with the original `devise_for` method. For example:
```ruby
# config/routes.rb
Rails.application.routes.draw do
Expand Down Expand Up @@ -223,7 +228,7 @@ class Api::V1::TokensController < YourBaseController
skip_before_action :verify_authenticity_token, raise: false

def create
service = Devise::Api::TokensService::V2::Create.call(params: params, resource_class: Customer || resource_class)
service = Devise::Api::TokensService::V2::Create.new(params: params, resource_class: Customer).call
if service.success?
render json: service.success, status: :created
else
Expand Down Expand Up @@ -273,9 +278,18 @@ curl --location --request GET 'http://127.0.0.1:3000/users/tokens/info' \
--header 'Authorization: Bearer <access_token>'
```

## Security recommendations

- **Send tokens in the `Authorization` header.** The default `authorization.location = :both` also accepts tokens as query/body params (e.g. `GET /users/tokens/info?access_token=...`), and URLs end up in server/proxy logs, browser history and `Referer` headers. Set `api.authorization.location = :header` unless you need params support.
- **Enable refresh token rotation** (`api.refresh_token.rotation_enabled = true`). Without it a refresh token stays valid until it expires, so a stolen one can be replayed. With rotation, every refresh revokes the presented token and replaying a rotated token revokes the whole token family.
- **Enable paranoid mode** (`api.paranoid = true`) if you don't want attackers to be able to check whether an email address has an account (account enumeration).
- **Rate limit the token endpoints.** The gem does not throttle `sign_in`/`sign_up`/`refresh`; use [rack-attack](https://github.com/rack/rack-attack) or an equivalent in front of them. Devise `lockable` (if enabled) only slows per-account brute force.
- **Be careful with `sign_up.extra_fields`.** Every listed field is mass-assignable at sign up and echoed in every token/info response.
- **Token secrets in logs.** The gem automatically adds `access_token`, `refresh_token` and `previous_refresh_token` to `filter_parameters` and filters them from the token model's `#inspect`, but raw SQL logging (e.g. `log_level = :debug` in production) can still print token values — keep production SQL logging off or filtered.

## Development

After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rake rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.

To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).

Expand Down
156 changes: 48 additions & 108 deletions app/controllers/devise/api/tokens_controller.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# frozen_string_literal: true

# rubocop:disable Metrics/ClassLength
module Devise
module Api
class TokensController < Devise.api.config.base_controller.constantize
Expand All @@ -10,144 +9,94 @@ class TokensController < Devise.api.config.base_controller.constantize

respond_to :json

# rubocop:disable Metrics/AbcSize
def sign_up
unless Devise.api.config.sign_up.enabled
error_response = Devise::Api::Responses::ErrorResponse.new(request, error: :sign_up_disabled,
resource_class: resource_class)

return render json: error_response.body, status: error_response.status
end
return render_error_response(error: :sign_up_disabled) unless Devise.api.config.sign_up.enabled

Devise.api.config.before_sign_up.call(sign_up_params, request, resource_class)

service = Devise::Api::ResourceOwnerService::SignUp.new(params: sign_up_params,
resource_class: resource_class).call

if service.success?
token = service.success

call_devise_trackable!(token.resource_owner)

token_response = Devise::Api::Responses::TokenResponse.new(request, token: token, action: __method__)

Devise.api.config.after_successful_sign_up.call(token.resource_owner, token, request)

return render json: token_response.body, status: token_response.status
end

error_response = Devise::Api::Responses::ErrorResponse.new(request,
resource_class: resource_class,
**service.failure)

render json: error_response.body, status: error_response.status
render_resource_owner_service_result(service, action: __method__)
end
# rubocop:enable Metrics/AbcSize

# rubocop:disable Metrics/AbcSize
def sign_in
Devise.api.config.before_sign_in.call(sign_in_params, request, resource_class)

service = Devise::Api::ResourceOwnerService::SignIn.new(params: sign_in_params,
resource_class: resource_class).call

if service.success?
token = service.success

call_devise_trackable!(token.resource_owner)

token_response = Devise::Api::Responses::TokenResponse.new(request, token: service.success,
action: __method__)

Devise.api.config.after_successful_sign_in.call(token.resource_owner, token, request)

return render json: token_response.body, status: token_response.status
end

error_response = Devise::Api::Responses::ErrorResponse.new(request,
resource_class: resource_class,
**service.failure)

render json: error_response.body, status: error_response.status
render_resource_owner_service_result(service, action: __method__)
end
# rubocop:enable Metrics/AbcSize

def info
token_response = Devise::Api::Responses::TokenResponse.new(request, token: current_devise_api_token,
action: __method__)

render json: token_response.body, status: token_response.status
render_token_response(current_devise_api_token, action: __method__)
end

# rubocop:disable Metrics/AbcSize
def revoke
Devise.api.config.before_revoke.call(current_devise_api_token, request)

service = Devise::Api::TokensService::Revoke.new(devise_api_token: current_devise_api_token).call
return render_error_response(**service.failure) if service.failure?

if service.success?
token_response = Devise::Api::Responses::TokenResponse.new(request, token: service.success,
action: __method__)

Devise.api.config.after_successful_revoke.call(service.success&.resource_owner, service.success, request)

return render json: token_response.body, status: token_response.status
end

error_response = Devise::Api::Responses::ErrorResponse.new(request,
resource_class: resource_class,
**service.failure)

render json: error_response.body, status: error_response.status
token = service.success
Devise.api.config.after_successful_revoke.call(token&.resource_owner, token, request)
render_token_response(token, action: __method__)
end
# rubocop:enable Metrics/AbcSize

# rubocop:disable Metrics/AbcSize
def refresh
unless Devise.api.config.refresh_token.enabled
error_response = Devise::Api::Responses::ErrorResponse.new(request,
resource_class: resource_class,
error: :refresh_token_disabled)

return render json: error_response.body, status: error_response.status
end
return render_error_response(error: :refresh_token_disabled) unless Devise.api.config.refresh_token.enabled
return render_error_response(error: :invalid_refresh_token) if current_devise_api_refresh_token.blank?
return handle_refresh_token_reuse if refresh_token_reused?
return render_error_response(error: :revoked_token) if current_devise_api_refresh_token.revoked?

if current_devise_api_refresh_token.blank?
error_response = Devise::Api::Responses::ErrorResponse.new(request, error: :invalid_token,
resource_class: resource_class)

return render json: error_response.body, status: error_response.status
end

if current_devise_api_refresh_token.revoked?
error_response = Devise::Api::Responses::ErrorResponse.new(request, error: :revoked_token,
resource_class: resource_class)
perform_refresh
end

return render json: error_response.body, status: error_response.status
end
private

def perform_refresh
Devise.api.config.before_refresh.call(current_devise_api_refresh_token, request)

service = Devise::Api::TokensService::Refresh.new(devise_api_token: current_devise_api_refresh_token).call
return render_error_response(**service.failure) if service.failure?

if service.success?
token_response = Devise::Api::Responses::TokenResponse.new(request, token: service.success,
action: __method__)
token = service.success
Devise.api.config.after_successful_refresh.call(token.resource_owner, token, request)
render_token_response(token, action: :refresh)
end

Devise.api.config.after_successful_refresh.call(service.success.resource_owner, service.success, request)
def render_resource_owner_service_result(service, action:)
return render_error_response(**service.failure) if service.failure?

return render json: token_response.body, status: token_response.status
end
token = service.success
call_devise_trackable!(token.resource_owner)
Devise.api.config.public_send("after_successful_#{action}").call(token.resource_owner, token, request)
render_token_response(token, action: action)
end

error_response = Devise::Api::Responses::ErrorResponse.new(request,
resource_class: resource_class,
**service.failure)
def render_token_response(token, action:)
token_response = Devise::Api::Responses::TokenResponse.new(request, token: token, action: action)

render json: token_response.body, status: token_response.status
end

def render_error_response(**failure)
error_response = Devise::Api::Responses::ErrorResponse.new(request, resource_class: resource_class, **failure)

render json: error_response.body, status: error_response.status
end
# rubocop:enable Metrics/AbcSize

private
# A revoked or already-refreshed refresh token presented again while rotation is enabled means the
# token was leaked or replayed: revoke the whole token family (OAuth2 Security BCP).
def refresh_token_reused?
Devise.api.config.refresh_token.rotation_enabled &&
(current_devise_api_refresh_token.revoked? || current_devise_api_refresh_token.refreshes.exists?)
end

def handle_refresh_token_reuse
current_devise_api_refresh_token.revoke_family!

render_error_response(error: :revoked_token)
end

def sign_up_params
params.permit(*Devise.api.config.sign_up.extra_fields, *resource_class.authentication_keys,
Expand All @@ -164,15 +113,6 @@ def call_devise_trackable!(resource_owner)

resource_owner.update_tracked_fields!(request)
end

def current_devise_api_refresh_token
return @current_devise_api_refresh_token if defined?(@current_devise_api_refresh_token)

token = find_devise_api_token
devise_api_token_model = Devise.api.config.base_token_model.constantize
@current_devise_api_refresh_token = devise_api_token_model.find_by(refresh_token: token)
end
end
end
end
# rubocop:enable Metrics/ClassLength
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,21 @@ class Authenticate < Devise::Api::BaseService

def call
resource = resource_class.find_for_authentication(params.slice(*resource_class.authentication_keys))
return Failure(error: :invalid_email, record: nil) if resource.blank?
return Failure(error: resource_not_found_error, record: nil) if resource.blank?
return Failure(error: :invalid_authentication, record: resource) unless authenticate!(resource)

Success(resource)
end

private

def resource_not_found_error
return :invalid_authentication if Devise.api.config.paranoid
return :invalid_email if resource_class.authentication_keys.map(&:to_sym).include?(:email)

:invalid_login
end

def authenticate!(resource)
resource.valid_for_authentication? do
resource.valid_password?(params[:password])
Expand Down
19 changes: 16 additions & 3 deletions app/services/devise/api/tokens_service/create.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ module Devise
module Api
module TokensService
class Create < Devise::Api::BaseService
# Retries after ActiveRecord::RecordNotUnique when two concurrent requests win the
# application-level uniqueness check with the same generated token (see unique DB indexes)
MAX_TOKEN_GENERATION_ATTEMPTS = 3

option :resource_owner
option :previous_refresh_token, type: Types::String | Types::Nil, default: proc { nil }

Expand All @@ -18,11 +22,20 @@ def call
private

def create_devise_api_token
devise_api_token = resource_owner.access_tokens.new(params)
attempts = 0

begin
devise_api_token = resource_owner.access_tokens.new(params)

return Success(devise_api_token) if devise_api_token.save

return Success(devise_api_token) if devise_api_token.save
Failure(error: :devise_api_token_create_error, record: devise_api_token)
rescue ::ActiveRecord::RecordNotUnique
attempts += 1
retry if attempts < MAX_TOKEN_GENERATION_ATTEMPTS

Failure(error: :devise_api_token_create_error, record: devise_api_token)
raise
end
end

def params
Expand Down
Loading
Loading