devise-api 0.2.0 → 0.3.0

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.
@@ -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
- token_response = Devise::Api::Responses::TokenResponse.new(request, token: current_devise_api_token,
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
- if service.success?
89
- token_response = Devise::Api::Responses::TokenResponse.new(request, token: service.success,
90
- action: __method__)
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
- error_response = Devise::Api::Responses::ErrorResponse.new(request,
109
- resource_class: resource_class,
110
- error: :refresh_token_disabled)
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
- if current_devise_api_refresh_token.blank?
116
- error_response = Devise::Api::Responses::ErrorResponse.new(request, error: :invalid_token,
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
- return render json: error_response.body, status: error_response.status
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
- if service.success?
134
- token_response = Devise::Api::Responses::TokenResponse.new(request, token: service.success,
135
- action: __method__)
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
- Devise.api.config.after_successful_refresh.call(service.success.resource_owner, service.success, request)
67
+ def render_resource_owner_service_result(service, action:)
68
+ return render_error_response(**service.failure) if service.failure?
138
69
 
139
- return render json: token_response.body, status: token_response.status
140
- end
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
- error_response = Devise::Api::Responses::ErrorResponse.new(request,
143
- resource_class: resource_class,
144
- **service.failure)
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
- private
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(email: params[:email])
12
- return Failure(error: :invalid_email, record: nil) if resource.blank?
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
- devise_api_token = resource_owner.access_tokens.new(params)
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
- return Success(devise_api_token) if devise_api_token.save
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
- Failure(error: :devise_api_token_create_error, record: devise_api_token)
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
- devise_api_token = yield create_devise_api_token
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.zone.now)
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
@@ -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) |