devise-api 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.claude/commands/commit.md +38 -0
- data/.claude/commands/create-branch.md +57 -0
- data/.claude/commands/create-pr.md +41 -0
- data/CHANGELOG.md +60 -1
- data/CLAUDE.md +49 -0
- data/Gemfile +9 -3
- data/Gemfile.lock +257 -185
- data/README.md +320 -162
- data/Rakefile +6 -0
- data/app/controllers/devise/api/tokens_controller.rb +48 -108
- data/app/services/devise/api/resource_owner_service/authenticate.rb +9 -2
- data/app/services/devise/api/tokens_service/create.rb +16 -8
- data/app/services/devise/api/tokens_service/refresh.rb +17 -2
- data/app/services/devise/api/tokens_service/revoke.rb +1 -1
- data/config/locales/en.yml +1 -0
- data/docs/README.md +32 -0
- data/docs/analysis/known-issues.md +56 -0
- data/docs/analysis/security-review.md +53 -0
- data/docs/api-reference.md +95 -0
- data/docs/architecture.md +145 -0
- data/docs/configuration.md +84 -0
- data/docs/data-model.md +80 -0
- data/docs/development.md +52 -0
- data/docs/extending.md +75 -0
- data/docs/services.md +72 -0
- data/docs/testing.md +65 -0
- data/lib/devise/api/configuration.rb +7 -0
- data/lib/devise/api/controllers/helpers.rb +5 -3
- data/lib/devise/api/generators/templates/migration.rb.erb +2 -2
- data/lib/devise/api/rails/engine.rb +6 -0
- data/lib/devise/api/responses/error_response.rb +40 -29
- data/lib/devise/api/responses/token_response.rb +5 -1
- data/lib/devise/api/token.rb +34 -2
- data/lib/devise/api/version.rb +1 -1
- metadata +18 -7
- data/sig/devise/api.rbs +0 -6
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# rubocop:disable Metrics/ClassLength
|
|
4
3
|
module Devise
|
|
5
4
|
module Api
|
|
6
5
|
class TokensController < Devise.api.config.base_controller.constantize
|
|
@@ -10,144 +9,94 @@ module Devise
|
|
|
10
9
|
|
|
11
10
|
respond_to :json
|
|
12
11
|
|
|
13
|
-
# rubocop:disable Metrics/AbcSize
|
|
14
12
|
def sign_up
|
|
15
|
-
unless Devise.api.config.sign_up.enabled
|
|
16
|
-
error_response = Devise::Api::Responses::ErrorResponse.new(request, error: :sign_up_disabled,
|
|
17
|
-
resource_class: resource_class)
|
|
18
|
-
|
|
19
|
-
return render json: error_response.body, status: error_response.status
|
|
20
|
-
end
|
|
13
|
+
return render_error_response(error: :sign_up_disabled) unless Devise.api.config.sign_up.enabled
|
|
21
14
|
|
|
22
15
|
Devise.api.config.before_sign_up.call(sign_up_params, request, resource_class)
|
|
23
16
|
|
|
24
17
|
service = Devise::Api::ResourceOwnerService::SignUp.new(params: sign_up_params,
|
|
25
18
|
resource_class: resource_class).call
|
|
26
|
-
|
|
27
|
-
if service.success?
|
|
28
|
-
token = service.success
|
|
29
|
-
|
|
30
|
-
call_devise_trackable!(token.resource_owner)
|
|
31
|
-
|
|
32
|
-
token_response = Devise::Api::Responses::TokenResponse.new(request, token: token, action: __method__)
|
|
33
|
-
|
|
34
|
-
Devise.api.config.after_successful_sign_up.call(token.resource_owner, token, request)
|
|
35
|
-
|
|
36
|
-
return render json: token_response.body, status: token_response.status
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
error_response = Devise::Api::Responses::ErrorResponse.new(request,
|
|
40
|
-
resource_class: resource_class,
|
|
41
|
-
**service.failure)
|
|
42
|
-
|
|
43
|
-
render json: error_response.body, status: error_response.status
|
|
19
|
+
render_resource_owner_service_result(service, action: __method__)
|
|
44
20
|
end
|
|
45
|
-
# rubocop:enable Metrics/AbcSize
|
|
46
21
|
|
|
47
|
-
# rubocop:disable Metrics/AbcSize
|
|
48
22
|
def sign_in
|
|
49
23
|
Devise.api.config.before_sign_in.call(sign_in_params, request, resource_class)
|
|
50
24
|
|
|
51
25
|
service = Devise::Api::ResourceOwnerService::SignIn.new(params: sign_in_params,
|
|
52
26
|
resource_class: resource_class).call
|
|
53
|
-
|
|
54
|
-
if service.success?
|
|
55
|
-
token = service.success
|
|
56
|
-
|
|
57
|
-
call_devise_trackable!(token.resource_owner)
|
|
58
|
-
|
|
59
|
-
token_response = Devise::Api::Responses::TokenResponse.new(request, token: service.success,
|
|
60
|
-
action: __method__)
|
|
61
|
-
|
|
62
|
-
Devise.api.config.after_successful_sign_in.call(token.resource_owner, token, request)
|
|
63
|
-
|
|
64
|
-
return render json: token_response.body, status: token_response.status
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
error_response = Devise::Api::Responses::ErrorResponse.new(request,
|
|
68
|
-
resource_class: resource_class,
|
|
69
|
-
**service.failure)
|
|
70
|
-
|
|
71
|
-
render json: error_response.body, status: error_response.status
|
|
27
|
+
render_resource_owner_service_result(service, action: __method__)
|
|
72
28
|
end
|
|
73
|
-
# rubocop:enable Metrics/AbcSize
|
|
74
29
|
|
|
75
30
|
def info
|
|
76
|
-
|
|
77
|
-
action: __method__)
|
|
78
|
-
|
|
79
|
-
render json: token_response.body, status: token_response.status
|
|
31
|
+
render_token_response(current_devise_api_token, action: __method__)
|
|
80
32
|
end
|
|
81
33
|
|
|
82
|
-
# rubocop:disable Metrics/AbcSize
|
|
83
34
|
def revoke
|
|
84
35
|
Devise.api.config.before_revoke.call(current_devise_api_token, request)
|
|
85
36
|
|
|
86
37
|
service = Devise::Api::TokensService::Revoke.new(devise_api_token: current_devise_api_token).call
|
|
38
|
+
return render_error_response(**service.failure) if service.failure?
|
|
87
39
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
Devise.api.config.after_successful_revoke.call(service.success&.resource_owner, service.success, request)
|
|
93
|
-
|
|
94
|
-
return render json: token_response.body, status: token_response.status
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
error_response = Devise::Api::Responses::ErrorResponse.new(request,
|
|
98
|
-
resource_class: resource_class,
|
|
99
|
-
**service.failure)
|
|
100
|
-
|
|
101
|
-
render json: error_response.body, status: error_response.status
|
|
40
|
+
token = service.success
|
|
41
|
+
Devise.api.config.after_successful_revoke.call(token&.resource_owner, token, request)
|
|
42
|
+
render_token_response(token, action: __method__)
|
|
102
43
|
end
|
|
103
|
-
# rubocop:enable Metrics/AbcSize
|
|
104
44
|
|
|
105
|
-
# rubocop:disable Metrics/AbcSize
|
|
106
45
|
def refresh
|
|
107
|
-
unless Devise.api.config.refresh_token.enabled
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return render json: error_response.body, status: error_response.status
|
|
113
|
-
end
|
|
46
|
+
return render_error_response(error: :refresh_token_disabled) unless Devise.api.config.refresh_token.enabled
|
|
47
|
+
return render_error_response(error: :invalid_refresh_token) if current_devise_api_refresh_token.blank?
|
|
48
|
+
return handle_refresh_token_reuse if refresh_token_reused?
|
|
49
|
+
return render_error_response(error: :revoked_token) if current_devise_api_refresh_token.revoked?
|
|
114
50
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
resource_class: resource_class)
|
|
118
|
-
|
|
119
|
-
return render json: error_response.body, status: error_response.status
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
if current_devise_api_refresh_token.revoked?
|
|
123
|
-
error_response = Devise::Api::Responses::ErrorResponse.new(request, error: :revoked_token,
|
|
124
|
-
resource_class: resource_class)
|
|
51
|
+
perform_refresh
|
|
52
|
+
end
|
|
125
53
|
|
|
126
|
-
|
|
127
|
-
end
|
|
54
|
+
private
|
|
128
55
|
|
|
56
|
+
def perform_refresh
|
|
129
57
|
Devise.api.config.before_refresh.call(current_devise_api_refresh_token, request)
|
|
130
58
|
|
|
131
59
|
service = Devise::Api::TokensService::Refresh.new(devise_api_token: current_devise_api_refresh_token).call
|
|
60
|
+
return render_error_response(**service.failure) if service.failure?
|
|
132
61
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
62
|
+
token = service.success
|
|
63
|
+
Devise.api.config.after_successful_refresh.call(token.resource_owner, token, request)
|
|
64
|
+
render_token_response(token, action: :refresh)
|
|
65
|
+
end
|
|
136
66
|
|
|
137
|
-
|
|
67
|
+
def render_resource_owner_service_result(service, action:)
|
|
68
|
+
return render_error_response(**service.failure) if service.failure?
|
|
138
69
|
|
|
139
|
-
|
|
140
|
-
|
|
70
|
+
token = service.success
|
|
71
|
+
call_devise_trackable!(token.resource_owner)
|
|
72
|
+
Devise.api.config.public_send("after_successful_#{action}").call(token.resource_owner, token, request)
|
|
73
|
+
render_token_response(token, action: action)
|
|
74
|
+
end
|
|
141
75
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
76
|
+
def render_token_response(token, action:)
|
|
77
|
+
token_response = Devise::Api::Responses::TokenResponse.new(request, token: token, action: action)
|
|
78
|
+
|
|
79
|
+
render json: token_response.body, status: token_response.status
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def render_error_response(**failure)
|
|
83
|
+
error_response = Devise::Api::Responses::ErrorResponse.new(request, resource_class: resource_class, **failure)
|
|
145
84
|
|
|
146
85
|
render json: error_response.body, status: error_response.status
|
|
147
86
|
end
|
|
148
|
-
# rubocop:enable Metrics/AbcSize
|
|
149
87
|
|
|
150
|
-
|
|
88
|
+
# A revoked or already-refreshed refresh token presented again while rotation is enabled means the
|
|
89
|
+
# token was leaked or replayed: revoke the whole token family (OAuth2 Security BCP).
|
|
90
|
+
def refresh_token_reused?
|
|
91
|
+
Devise.api.config.refresh_token.rotation_enabled &&
|
|
92
|
+
(current_devise_api_refresh_token.revoked? || current_devise_api_refresh_token.refreshes.exists?)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def handle_refresh_token_reuse
|
|
96
|
+
current_devise_api_refresh_token.revoke_family!
|
|
97
|
+
|
|
98
|
+
render_error_response(error: :revoked_token)
|
|
99
|
+
end
|
|
151
100
|
|
|
152
101
|
def sign_up_params
|
|
153
102
|
params.permit(*Devise.api.config.sign_up.extra_fields, *resource_class.authentication_keys,
|
|
@@ -164,15 +113,6 @@ module Devise
|
|
|
164
113
|
|
|
165
114
|
resource_owner.update_tracked_fields!(request)
|
|
166
115
|
end
|
|
167
|
-
|
|
168
|
-
def current_devise_api_refresh_token
|
|
169
|
-
return @current_devise_api_refresh_token if @current_devise_api_refresh_token
|
|
170
|
-
|
|
171
|
-
token = find_devise_api_token
|
|
172
|
-
devise_api_token_model = Devise.api.config.base_token_model.constantize
|
|
173
|
-
@current_devise_api_refresh_token = devise_api_token_model.find_by(refresh_token: token)
|
|
174
|
-
end
|
|
175
116
|
end
|
|
176
117
|
end
|
|
177
118
|
end
|
|
178
|
-
# rubocop:enable Metrics/ClassLength
|
|
@@ -8,8 +8,8 @@ module Devise
|
|
|
8
8
|
option :resource_class, type: Types::Class
|
|
9
9
|
|
|
10
10
|
def call
|
|
11
|
-
resource = resource_class.find_for_authentication(
|
|
12
|
-
return Failure(error:
|
|
11
|
+
resource = resource_class.find_for_authentication(params.slice(*resource_class.authentication_keys))
|
|
12
|
+
return Failure(error: resource_not_found_error, record: nil) if resource.blank?
|
|
13
13
|
return Failure(error: :invalid_authentication, record: resource) unless authenticate!(resource)
|
|
14
14
|
|
|
15
15
|
Success(resource)
|
|
@@ -17,6 +17,13 @@ module Devise
|
|
|
17
17
|
|
|
18
18
|
private
|
|
19
19
|
|
|
20
|
+
def resource_not_found_error
|
|
21
|
+
return :invalid_authentication if Devise.api.config.paranoid
|
|
22
|
+
return :invalid_email if resource_class.authentication_keys.map(&:to_sym).include?(:email)
|
|
23
|
+
|
|
24
|
+
:invalid_login
|
|
25
|
+
end
|
|
26
|
+
|
|
20
27
|
def authenticate!(resource)
|
|
21
28
|
resource.valid_for_authentication? do
|
|
22
29
|
resource.valid_password?(params[:password])
|
|
@@ -4,6 +4,10 @@ module Devise
|
|
|
4
4
|
module Api
|
|
5
5
|
module TokensService
|
|
6
6
|
class Create < Devise::Api::BaseService
|
|
7
|
+
# Retries after ActiveRecord::RecordNotUnique when two concurrent requests win the
|
|
8
|
+
# application-level uniqueness check with the same generated token (see unique DB indexes)
|
|
9
|
+
MAX_TOKEN_GENERATION_ATTEMPTS = 3
|
|
10
|
+
|
|
7
11
|
option :resource_owner
|
|
8
12
|
option :previous_refresh_token, type: Types::String | Types::Nil, default: proc { nil }
|
|
9
13
|
|
|
@@ -17,17 +21,21 @@ module Devise
|
|
|
17
21
|
|
|
18
22
|
private
|
|
19
23
|
|
|
20
|
-
def authenticate_service
|
|
21
|
-
Devise::Api::ResourceOwnerService::Authenticate.new(params: params,
|
|
22
|
-
resource_class: resource_class).call
|
|
23
|
-
end
|
|
24
|
-
|
|
25
24
|
def create_devise_api_token
|
|
26
|
-
|
|
25
|
+
attempts = 0
|
|
26
|
+
|
|
27
|
+
begin
|
|
28
|
+
devise_api_token = resource_owner.access_tokens.new(params)
|
|
29
|
+
|
|
30
|
+
return Success(devise_api_token) if devise_api_token.save
|
|
27
31
|
|
|
28
|
-
|
|
32
|
+
Failure(error: :devise_api_token_create_error, record: devise_api_token)
|
|
33
|
+
rescue ::ActiveRecord::RecordNotUnique
|
|
34
|
+
attempts += 1
|
|
35
|
+
retry if attempts < MAX_TOKEN_GENERATION_ATTEMPTS
|
|
29
36
|
|
|
30
|
-
|
|
37
|
+
raise
|
|
38
|
+
end
|
|
31
39
|
end
|
|
32
40
|
|
|
33
41
|
def params
|
|
@@ -9,9 +9,9 @@ module Devise
|
|
|
9
9
|
|
|
10
10
|
def call
|
|
11
11
|
return Failure(error: :expired_refresh_token) if devise_api_token.refresh_token_expired?
|
|
12
|
+
return create_devise_api_token unless Devise.api.config.refresh_token.rotation_enabled
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
Success(devise_api_token)
|
|
14
|
+
create_devise_api_token_with_rotation
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
private
|
|
@@ -20,6 +20,21 @@ module Devise
|
|
|
20
20
|
Devise::Api::TokensService::Create.new(resource_owner: resource_owner,
|
|
21
21
|
previous_refresh_token: devise_api_token.refresh_token).call
|
|
22
22
|
end
|
|
23
|
+
|
|
24
|
+
# Mints the replacement token and revokes the presented refresh token atomically, so a
|
|
25
|
+
# rotated refresh token can never be replayed (its reuse triggers family revocation upstream)
|
|
26
|
+
def create_devise_api_token_with_rotation
|
|
27
|
+
result = nil
|
|
28
|
+
|
|
29
|
+
devise_api_token.class.transaction do
|
|
30
|
+
result = create_devise_api_token
|
|
31
|
+
raise ::ActiveRecord::Rollback if result.failure?
|
|
32
|
+
|
|
33
|
+
devise_api_token.revoke!
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
result
|
|
37
|
+
end
|
|
23
38
|
end
|
|
24
39
|
end
|
|
25
40
|
end
|
|
@@ -9,7 +9,7 @@ module Devise
|
|
|
9
9
|
def call
|
|
10
10
|
return Success(devise_api_token) if devise_api_token.blank?
|
|
11
11
|
return Success(devise_api_token) if devise_api_token.revoked? || devise_api_token.expired?
|
|
12
|
-
return Success(devise_api_token) if devise_api_token.update(revoked_at: Time.
|
|
12
|
+
return Success(devise_api_token) if devise_api_token.update(revoked_at: Time.current)
|
|
13
13
|
|
|
14
14
|
Failure(error: :devise_api_token_revoke_error, record: devise_api_token)
|
|
15
15
|
end
|
data/config/locales/en.yml
CHANGED
|
@@ -11,6 +11,7 @@ en:
|
|
|
11
11
|
sign_up_disabled: "Sign up is disabled for this application"
|
|
12
12
|
invalid_refresh_token: "Refresh token is invalid"
|
|
13
13
|
invalid_email: "Email is invalid"
|
|
14
|
+
invalid_login: "Login credentials are invalid"
|
|
14
15
|
invalid_resource_owner: "Resource owner is invalid"
|
|
15
16
|
resource_owner_create_error: "Resource owner could not be created"
|
|
16
17
|
devise_api_token_create_error: "Token could not be created"
|
data/docs/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# devise-api Documentation
|
|
2
|
+
|
|
3
|
+
Internal documentation for contributors and AI coding agents. These documents describe the codebase as it **is** (reference docs) and as it **should evolve** (analysis docs). Keep them in sync with the code: any PR that changes behavior described here must update the matching document.
|
|
4
|
+
|
|
5
|
+
## Reference
|
|
6
|
+
|
|
7
|
+
| Document | Contents |
|
|
8
|
+
|----------|----------|
|
|
9
|
+
| [architecture.md](architecture.md) | Big-picture design: components, boot/integration sequence, request lifecycle, diagrams |
|
|
10
|
+
| [api-reference.md](api-reference.md) | HTTP endpoints, request/response payloads, full error catalog with statuses |
|
|
11
|
+
| [configuration.md](configuration.md) | Every `Devise.api.config` setting: type, default, where it is consumed |
|
|
12
|
+
| [data-model.md](data-model.md) | `devise_api_tokens` schema, token state machine, refresh chains |
|
|
13
|
+
| [services.md](services.md) | Service-object contracts: inputs, success/failure values, composition |
|
|
14
|
+
| [extending.md](extending.md) | Supported customization points for host applications |
|
|
15
|
+
| [testing.md](testing.md) | Test layout, dummy app, helpers/factories, conventions, coverage map |
|
|
16
|
+
| [development.md](development.md) | Setup, commands, CI, release process |
|
|
17
|
+
|
|
18
|
+
## Analysis (working documents)
|
|
19
|
+
|
|
20
|
+
| Document | Contents |
|
|
21
|
+
|----------|----------|
|
|
22
|
+
| [analysis/security-review.md](analysis/security-review.md) | Security posture review: findings ranked by severity, with remediation notes |
|
|
23
|
+
| [analysis/known-issues.md](analysis/known-issues.md) | Code-quality findings: bugs, dead code, inconsistencies, doc drift |
|
|
24
|
+
|
|
25
|
+
## Ground rules for AI-driven development in this repo
|
|
26
|
+
|
|
27
|
+
1. **Read [architecture.md](architecture.md) first.** It explains the two invariants that shape everything: the single `Devise.api.config` global, and the string-based `base_token_model` / `base_controller` indirection (`constantize` at use sites — never hardcode `Devise::Api::Token` or the controller class in library code).
|
|
28
|
+
2. **Behavioral changes need request specs.** End-to-end coverage lives in `spec/requests/`; service specs (`spec/services/`) assert the monad contracts (see [testing.md](testing.md)).
|
|
29
|
+
3. **Error types are public API.** Adding/renaming a symbol in `ErrorResponse::ERROR_TYPES` requires a locale entry in `config/locales/en.yml`, a status mapping, and an entry in [api-reference.md](api-reference.md).
|
|
30
|
+
4. **Schema changes touch three places:** the generator template (`lib/devise/api/generators/templates/migration.rb.erb`), the dummy app (`spec/dummy/db/migrate` + `spec/dummy/db/schema.rb`), and [data-model.md](data-model.md). Host apps upgrade via new migrations, so also consider an upgrade path.
|
|
31
|
+
5. **Run `bundle exec rake` before finishing** — it runs RSpec and RuboCop, exactly what CI runs.
|
|
32
|
+
6. **Check [analysis/known-issues.md](analysis/known-issues.md) before "fixing" something** — several quirks are documented there with context on whether changing them breaks the public API (e.g. the `failed_attemps` response-field typo).
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Known Issues & Code-Quality Findings
|
|
2
|
+
|
|
3
|
+
Working backlog from a full-codebase review (`main` @ `bd49310`, v0.2.0), updated after the 2026-08 fix pass. Ordered by user impact. Security-relevant items live in [security-review.md](security-review.md) and are only cross-referenced here. Check this list before "fixing" surprising code — some quirks are shipped public API.
|
|
4
|
+
|
|
5
|
+
## Open
|
|
6
|
+
|
|
7
|
+
### KI-8 · `refresh_token.expires_in` is not snapshotted per row
|
|
8
|
+
Access-token TTL is copied into the row (`expires_in` column); refresh-token TTL is computed from *live config* at check time, so changing the config re-times every existing token (see [data-model.md](../data-model.md)). At minimum keep documented; ideally add a `refresh_token_expires_in` column for symmetry.
|
|
9
|
+
|
|
10
|
+
### KI-14 · Non-default configuration is untested (mostly resolved)
|
|
11
|
+
`spec/requests/configuration_overrides_spec.rb` covers `authorization.location = :header`/`:params` exclusively, `sign_up.enabled = false`, `refresh_token.enabled = false` and `sign_up.extra_fields` end-to-end; `spec/requests/refresh_token_rotation_spec.rb` and `spec/requests/paranoid_mode_spec.rb` cover `rotation_enabled`, `paranoid` and `verbose_account_state`; `spec/devise/api/token_spec.rb` covers `expires_in_infinite` procs and custom generators; `spec/devise/api/configuration_spec.rb` covers overrides on fresh instances. Still untested: custom `authorization.key`/`scheme`/`params_key` and `base_token_model`/`base_controller` overrides.
|
|
12
|
+
|
|
13
|
+
## Resolved
|
|
14
|
+
|
|
15
|
+
### KI-1 · ~~`failed_attemps` typo is public API~~ (resolved 2026-08)
|
|
16
|
+
`ErrorResponse#devise_lockable_info` now emits both the canonical `failed_attempts` and the deprecated misspelled `failed_attemps` (kept for backward compatibility). Drop the typo at the next major release; changelogged.
|
|
17
|
+
|
|
18
|
+
### KI-2 · ~~`invalid_refresh_token` error type is unreachable~~ (resolved 2026-08)
|
|
19
|
+
The `refresh` action now returns `invalid_refresh_token` (400) for missing/unknown refresh tokens instead of the generic `invalid_token` (401). Breaking-ish for clients matching on the error symbol; changelogged.
|
|
20
|
+
|
|
21
|
+
### KI-3 · ~~`invalid_email` hardcodes "email" while lookup uses `authentication_keys`~~ (resolved 2026-08)
|
|
22
|
+
`Authenticate` now returns `invalid_email` only when `:email` is one of the model's `authentication_keys`, and a new generic `invalid_login` (400, "Login credentials are invalid") otherwise. With `paranoid` enabled, both collapse into `invalid_authentication`.
|
|
23
|
+
|
|
24
|
+
### KI-4 · ~~Duplicate, divergent `current_devise_api_refresh_token`~~ (resolved 2026-08)
|
|
25
|
+
Consolidated into `Controllers::Helpers` with memoization mirroring `current_devise_api_token`; the controller-level override was deleted.
|
|
26
|
+
|
|
27
|
+
### KI-5 · ~~Dead method in `TokensService::Create`~~ (resolved)
|
|
28
|
+
`#authenticate_service` was never called and referenced `params` / `resource_class`, which didn't exist on this service — it would have `NameError`d if invoked. Copy-paste leftover; deleted as part of the coverage push.
|
|
29
|
+
|
|
30
|
+
### KI-6 · ~~RBS stub~~ (resolved 2026-08)
|
|
31
|
+
`sig/devise/api.rbs` declared only the `VERSION` constant; the `sig/` directory was removed.
|
|
32
|
+
|
|
33
|
+
### KI-7 · ~~Mixed time sources~~ (resolved 2026-08)
|
|
34
|
+
Standardized on `Time.current` (`Token#expired?`/`#refresh_token_expired?`/`#revoke!`, `TokensService::Revoke`).
|
|
35
|
+
|
|
36
|
+
### KI-9 · ~~CHANGELOG stale~~ (resolved 2026-08)
|
|
37
|
+
Backfilled 0.1.0 → 0.2.0 from git history plus an Unreleased section for the current pass. Releases without changelog entries should fail review.
|
|
38
|
+
|
|
39
|
+
### KI-10 · ~~Controller carries rubocop-disable scar tissue~~ (resolved 2026-08)
|
|
40
|
+
`TokensController` now uses private `render_token_response` / `render_error_response` / `render_resource_owner_service_result` / `perform_refresh` helpers; all `rubocop:disable` comments are gone.
|
|
41
|
+
|
|
42
|
+
### KI-11 · ~~README nits~~ (resolved 2026-08)
|
|
43
|
+
Fixed the "orginally" typo, the `rake rspec` command, and the service example (`.new(...).call` instead of the non-existent class-level `.call`).
|
|
44
|
+
|
|
45
|
+
### KI-12 · ~~Service specs are placeholders~~ (resolved)
|
|
46
|
+
All six `spec/services/**` files now assert the monad contracts (`Success`/`Failure` per branch, per [services.md](../services.md)), including the failure paths unreachable through the HTTP API (`:invalid_resource_owner`, `:devise_api_token_create_error`, `:devise_api_token_revoke_error`, sign-up transaction rollback).
|
|
47
|
+
|
|
48
|
+
### KI-13 · ~~No generator specs~~ (resolved)
|
|
49
|
+
`rails g devise_api:install` is covered by `spec/devise/api/generators/install_generator_spec.rb` (migration template rendering with the current Active Record version, unique token indexes, locale copy, migration numbering).
|
|
50
|
+
|
|
51
|
+
## Cross-references into security review
|
|
52
|
+
|
|
53
|
+
- Non-unique token indexes → [SEC-4](security-review.md) (resolved)
|
|
54
|
+
- Plaintext token storage → [SEC-1](security-review.md) (open; log filtering shipped)
|
|
55
|
+
- No refresh rotation → [SEC-2](security-review.md) (resolved, opt-in `rotation_enabled`)
|
|
56
|
+
- Default `:both` token location + GET `info` → [SEC-3](security-review.md) (documented; default unchanged)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Security Review
|
|
2
|
+
|
|
3
|
+
Static review of the codebase as of `main` @ `bd49310` (v0.2.0, 2026-08), updated after the 2026-08 hardening pass. This is the working document for the "security hardening" milestone: each finding has an ID, severity, and remediation sketch. When a finding is fixed, move it to the *Resolved* section with the PR reference.
|
|
4
|
+
|
|
5
|
+
Severity scale: **High** = practical account/session compromise under a realistic threat model; **Medium** = meaningful weakening of the security posture; **Low** = defense-in-depth / hardening; **Info** = document-and-accept candidates.
|
|
6
|
+
|
|
7
|
+
## Open findings
|
|
8
|
+
|
|
9
|
+
### SEC-1 · High · Tokens stored in plaintext
|
|
10
|
+
`devise_api_tokens.access_token` / `refresh_token` are stored as-is (`lib/devise/api/token.rb`, migration template). Anyone with read access to the DB (backup leak, SQL injection elsewhere in the host app, log of the row) holds live bearer credentials for every active session.
|
|
11
|
+
|
|
12
|
+
**Remediation:** store a digest (e.g. `SHA256`) and look up by digest; return the raw token only once at creation. Needs a migration path (dual-read window or forced re-login) and is a breaking change for host apps that query tokens directly — consider a `hash_token_secrets` config flag defaulting on in the next minor. This also resolves SEC-6.
|
|
13
|
+
|
|
14
|
+
**Partial mitigation shipped (2026-08):** token values are now filtered from request logs (`filter_parameters` via the engine initializer) and from `Token#inspect` (`filter_attributes`), addressing GH-51. Raw SQL logging can still print values; digest storage remains the real fix.
|
|
15
|
+
|
|
16
|
+
### SEC-3 · Medium · Tokens accepted in URL params by default
|
|
17
|
+
`authorization.location` defaults to `:both`, and `info` is a GET route — so `GET /users/tokens/info?access_token=…` is a documented usage. Query-string tokens end up in server/proxy/CDN access logs, browser history, and potentially `Referer` headers.
|
|
18
|
+
|
|
19
|
+
**Remediation:** change the default to `:header` (breaking; needs a major-version changelog callout). **Interim (shipped 2026-08):** the README "Security recommendations" section now tells host apps to set `api.authorization.location = :header`, and token params are filtered from request logs. `:params`/`:both` remain opt-in-by-default until the next breaking release.
|
|
20
|
+
|
|
21
|
+
### SEC-6 · Low · Token lookup is not constant-time
|
|
22
|
+
`find_by(access_token: token)` compares via DB index. With 60-char `friendly_token` entropy the timing side channel is not practically exploitable, noted for completeness. Hashing tokens (SEC-1) makes this moot.
|
|
23
|
+
|
|
24
|
+
### SEC-8 · Info · No rate limiting
|
|
25
|
+
The gem relies entirely on Devise `lockable` (if enabled) to slow credential stuffing; `sign_in`/`sign_up`/`refresh` are otherwise unthrottled. Out of scope to implement in-gem. The README "Security recommendations" section now recommends `rack-attack` (or equivalent) on the token endpoints; keeping open as Info in case in-gem throttling hooks are ever wanted.
|
|
26
|
+
|
|
27
|
+
### SEC-9 · Info · `sign_up.extra_fields` is a mass-assignment and disclosure lever
|
|
28
|
+
Fields listed there are both *writable at sign-up* and *echoed in every token/info response* (`TokenResponse#default_resource_owner`). A host app adding `:role` or `:admin` here creates a privilege-escalation hole. Warnings shipped 2026-08 in the README (config example + "Security recommendations"); keeping open as Info because the sharp edge itself remains.
|
|
29
|
+
|
|
30
|
+
### SEC-10 · Info · Deliberate CSRF skip
|
|
31
|
+
`skip_before_action :verify_authenticity_token` is correct for bearer-token endpoints; note that accepting tokens from params (`SEC-3`) is what keeps CSRF relevant — cookie-less bearer auth in the header is not CSRF-able.
|
|
32
|
+
|
|
33
|
+
## Positive observations
|
|
34
|
+
|
|
35
|
+
- Default generators use `Devise.friendly_token(60)` — ample entropy, URL-safe.
|
|
36
|
+
- Password verification delegates to Devise (`valid_password?` is constant-time via `Devise.secure_compare` internally; lockable counters handled by `valid_for_authentication?`).
|
|
37
|
+
- `dependent: :destroy` on the owner association prevents orphaned live tokens after account deletion.
|
|
38
|
+
- Params are strictly `permit`ted from `authentication_keys` + Devise defaults + explicit config.
|
|
39
|
+
- Sign-up wraps user+token creation in a transaction; do-notation failure unwinds roll it back.
|
|
40
|
+
|
|
41
|
+
## Resolved
|
|
42
|
+
|
|
43
|
+
### SEC-2 · Medium · No refresh-token rotation invalidation or reuse detection — *resolved 2026-08 (opt-in)*
|
|
44
|
+
`TokensService::Refresh` minted a new token but left the presented refresh token fully usable until its own TTL. Fixed with the OAuth2 Security BCP pattern behind `refresh_token.rotation_enabled` (default `false` for backward compatibility): each refresh revokes the presented token (same transaction as the mint), and presenting a rotated/revoked refresh token again revokes the whole token family (`Token#revoke_family!`) and returns `revoked_token`. Recommended `true` in the README; consider defaulting on at the next breaking release. Covered by `spec/requests/refresh_token_rotation_spec.rb` and `spec/services/tokens_service/refresh_spec.rb`.
|
|
45
|
+
|
|
46
|
+
### SEC-4 · Low · Token uniqueness not enforced by the database — *resolved 2026-08*
|
|
47
|
+
The migration template now creates `unique: true` indexes on `access_token` and `refresh_token` (existing installs: add the migration listed in the CHANGELOG), and `TokensService::Create` rescues `ActiveRecord::RecordNotUnique` with a regenerate-and-retry (3 attempts). Covered by `spec/devise/api/token_spec.rb` ("database uniqueness") and `spec/services/tokens_service/create_spec.rb`.
|
|
48
|
+
|
|
49
|
+
### SEC-5 · Low · Account enumeration via differentiated errors — *resolved 2026-08 (opt-in)*
|
|
50
|
+
`paranoid` config flag (default `false`, mirroring Devise's `config.paranoid`): unknown accounts, wrong passwords, and locked/unconfirmed accounts all return the same generic `invalid_authentication` (401) with no lockable/confirmable details. Covered by `spec/requests/paranoid_mode_spec.rb`.
|
|
51
|
+
|
|
52
|
+
### SEC-7 · Low · Lockable error payload aids brute-force pacing — *resolved 2026-08 (opt-in)*
|
|
53
|
+
`error_response.verbose_account_state` config flag (default `true` for compatibility, recommended `false`): when disabled, the `lockable`/`confirmable` metadata blocks (`max_attempts`, `failed_attempts`, `locked_at`, `unlock_at`, …) are omitted from error responses. `paranoid` implies it. Covered by `spec/requests/paranoid_mode_spec.rb`.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# HTTP API Reference
|
|
2
|
+
|
|
3
|
+
All endpoints are drawn by `devise_for :<scope>` for any model with the `:api` module. Examples below use `devise_for :users`. Path segment (`tokens`) and controller are customizable — see [extending.md](extending.md).
|
|
4
|
+
|
|
5
|
+
| Route helper | Verb | Path | Action | Auth required |
|
|
6
|
+
|---|---|---|---|---|
|
|
7
|
+
| `sign_up_user_tokens` | POST | `/users/tokens/sign_up` | `sign_up` | no |
|
|
8
|
+
| `sign_in_user_tokens` | POST | `/users/tokens/sign_in` | `sign_in` | no |
|
|
9
|
+
| `refresh_user_tokens` | POST | `/users/tokens/refresh` | `refresh` | refresh token |
|
|
10
|
+
| `revoke_user_tokens` | POST | `/users/tokens/revoke` | `revoke` | access token (silently no-ops if absent/invalid) |
|
|
11
|
+
| `info_user_tokens` | GET | `/users/tokens/info` | `info` | access token (via `authenticate_devise_api_token!`) |
|
|
12
|
+
|
|
13
|
+
Tokens are sent per `authorization` config (default `:both`): `Authorization: Bearer <token>` header **or** `access_token` query/body param (params win over header). The **same extraction** is used for access and refresh tokens — `refresh` expects the *refresh* token in the same slot.
|
|
14
|
+
|
|
15
|
+
With `refresh_token.rotation_enabled` (default off), a successful `refresh` also revokes the presented refresh token, and presenting a rotated/revoked refresh token again revokes its whole token family and returns `revoked_token` (reuse detection).
|
|
16
|
+
|
|
17
|
+
## Success payloads
|
|
18
|
+
|
|
19
|
+
Built by `Devise::Api::Responses::TokenResponse` (`lib/devise/api/responses/token_response.rb`).
|
|
20
|
+
|
|
21
|
+
### Default token body (`sign_in` 200, `refresh` 200, `sign_up` 201)
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"token": "<access_token>",
|
|
26
|
+
"refresh_token": "<refresh_token>",
|
|
27
|
+
"expires_in": 3600,
|
|
28
|
+
"token_type": "Bearer",
|
|
29
|
+
"resource_owner": { "id": 1, "email": "a@b.c", "created_at": "...", "updated_at": "..." }
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
- `refresh_token` is omitted (`compact`) when `refresh_token.enabled` is `false`.
|
|
34
|
+
- `resource_owner` contains `id, email, created_at, updated_at` plus any configured `sign_up.extra_fields`.
|
|
35
|
+
- `sign_up` on a confirmable model additionally merges `"confirmable": { "confirmed": false, "message": "<signed_up_but_unconfirmed locale>" }` and still returns `201` with tokens (unconfirmed users get tokens at sign-up; they cannot sign *in* until confirmed).
|
|
36
|
+
|
|
37
|
+
### `info` (200)
|
|
38
|
+
|
|
39
|
+
Returns just the `resource_owner` object (same key set as above).
|
|
40
|
+
|
|
41
|
+
### `revoke` (204)
|
|
42
|
+
|
|
43
|
+
Empty body. Returned even when no/invalid token was supplied (revoke is idempotent-by-design; see known-issues).
|
|
44
|
+
|
|
45
|
+
## Error payload
|
|
46
|
+
|
|
47
|
+
Built by `Devise::Api::Responses::ErrorResponse` (`lib/devise/api/responses/error_response.rb`):
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"error": "<error_type>",
|
|
52
|
+
"error_description": ["human readable message(s)"],
|
|
53
|
+
"lockable": { "locked": true, "max_attempts": 5, "failed_attempts": 5, "failed_attemps": 5, "locked_at": "...", "unlock_at": "..." },
|
|
54
|
+
"confirmable": { "confirmed": false, "confirmation_sent_at": "..." }
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- `lockable` / `confirmable` blocks appear **only** for `invalid_authentication` errors on models with those Devise modules (`compact` removes them otherwise), and only while `error_response.verbose_account_state` is `true` (the default) and `paranoid` is `false` — see [configuration.md](configuration.md).
|
|
59
|
+
- `failed_attempts` is the canonical key; `failed_attemps` is the original shipped typo, kept for backward compatibility until the next major release (see [analysis/known-issues.md](analysis/known-issues.md)).
|
|
60
|
+
- `error_description` comes from `record.errors.full_messages` when a record with validation errors is attached; otherwise from `config/locales/en.yml` under `devise.api.error_response.<error>`. With `paranoid` enabled, `invalid_authentication` always uses the generic message (no locked/unconfirmed specialization).
|
|
61
|
+
|
|
62
|
+
## Error catalog
|
|
63
|
+
|
|
64
|
+
Source of truth: `ErrorResponse::ERROR_TYPES` + `#status`. Every symbol must have a locale entry.
|
|
65
|
+
|
|
66
|
+
| `error` | HTTP status | Raised by / when |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `invalid_authentication` | 401 | `Authenticate` — bad password, locked, or unconfirmed account (description specializes per module state unless `paranoid`); with `paranoid` enabled, also returned when no account matches |
|
|
69
|
+
| `invalid_token` | 401 | helpers `authenticate_devise_api_token!` (no/unknown access token) |
|
|
70
|
+
| `expired_token` | 401 | helpers — access token past `expires_in` |
|
|
71
|
+
| `expired_refresh_token` | 401 | `TokensService::Refresh` — refresh token past `refresh_token.expires_in` |
|
|
72
|
+
| `revoked_token` | 401 | helpers / `refresh` — token has `revoked_at`; also the reuse-detection response when `refresh_token.rotation_enabled` revokes a token family |
|
|
73
|
+
| `invalid_email` | 400 | `Authenticate` — no resource found and `:email` is one of the model's `authentication_keys` (and `paranoid` is off) |
|
|
74
|
+
| `invalid_login` | 400 | `Authenticate` — no resource found and the model authenticates by non-email keys (and `paranoid` is off) |
|
|
75
|
+
| `invalid_refresh_token` | 400 | `refresh` action — no/unknown refresh token presented |
|
|
76
|
+
| `refresh_token_disabled` | 400 | `refresh` action when `refresh_token.enabled` is false |
|
|
77
|
+
| `sign_up_disabled` | 400 | `sign_up` action when `sign_up.enabled` is false |
|
|
78
|
+
| `invalid_resource_owner` | 400 | `TokensService::Create` — owner doesn't respond to `access_tokens` (model lacks `:api`) |
|
|
79
|
+
| `resource_owner_create_error` | 422 | `SignUp` — model validation failure (description = validation messages) |
|
|
80
|
+
| `devise_api_token_create_error` | 422 | `TokensService::Create` — token row failed to save |
|
|
81
|
+
| `devise_api_token_revoke_error` | 422 | `TokensService::Revoke` — update failed |
|
|
82
|
+
|
|
83
|
+
## Accepted parameters
|
|
84
|
+
|
|
85
|
+
- **sign_in**: `resource_class.authentication_keys` (usually `email`) + Devise's default sign-in params (`password`, `remember_me`).
|
|
86
|
+
- **sign_up**: authentication keys + Devise's default sign-up params (`password`, `password_confirmation`) + `Devise.api.config.sign_up.extra_fields`.
|
|
87
|
+
- Params are `permit`ted then `.to_h` — anything else is dropped.
|
|
88
|
+
|
|
89
|
+
## Devise module interplay
|
|
90
|
+
|
|
91
|
+
| Devise module on the model | Effect |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `trackable` | `sign_in`/`sign_up` call `update_tracked_fields!(request)` |
|
|
94
|
+
| `lockable` | failed sign-ins increment `failed_attempts` (via `valid_for_authentication?`); lock state reported in error payload; successful sign-in resets attempts |
|
|
95
|
+
| `confirmable` | unconfirmed users can sign **up** (get tokens + confirmable notice) but cannot sign **in** (`active_for_authentication?` fails → `invalid_authentication` with unconfirmed description) |
|