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.
@@ -0,0 +1,145 @@
1
+ # Architecture
2
+
3
+ `devise-api` is a Rails engine gem that adds opaque-token API authentication (access + refresh tokens) on top of [Devise](https://github.com/heartcombo/devise). It plugs into Devise's own extension mechanism: it registers an `:api` module via `Devise.add_module`, so host models opt in with `devise :api` and routes appear automatically through `devise_for`.
4
+
5
+ ## Core invariants
6
+
7
+ These two decisions shape every file in the gem:
8
+
9
+ 1. **One global configuration object.** `Devise.api` (installed in `lib/devise/api.rb` as a `mattr_accessor` holding a `Devise::Api::Configuration` instance) is the single source of truth. Every tunable — token TTLs, generators, callbacks, header names, base classes — is read at *use time* from `Devise.api.config.*`. There is no per-model configuration.
10
+ 2. **Base classes are strings, resolved lazily.** `base_token_model` (default `'Devise::Api::Token'`) and `base_controller` (default `'::DeviseController'`) are stored as class *names* and `constantize`d at each use site. This lets host apps substitute subclasses without load-order problems. **Library code must never reference `Devise::Api::Token` directly** — always go through `Devise.api.config.base_token_model.constantize`.
11
+
12
+ ## Component map
13
+
14
+ ```mermaid
15
+ graph TD
16
+ subgraph "Host Rails app"
17
+ RM["Model with `devise :api`"]
18
+ RT["routes.rb — devise_for :users"]
19
+ INIT["initializer — Devise.setup { config.api.configure }"]
20
+ end
21
+
22
+ subgraph "Routing layer"
23
+ MAP["ActionDispatch::Routing::Mapper#devise_api<br/>lib/devise/api/rails/routes.rb"]
24
+ end
25
+
26
+ subgraph "Controller layer"
27
+ TC["Devise::Api::TokensController<br/>app/controllers/devise/api/tokens_controller.rb"]
28
+ HLP["Controllers::Helpers (mixed into ALL ActionController)<br/>lib/devise/api/controllers/helpers.rb"]
29
+ end
30
+
31
+ subgraph "Service layer (dry-monads)"
32
+ SI["ResourceOwnerService::SignIn"]
33
+ SU["ResourceOwnerService::SignUp"]
34
+ AU["ResourceOwnerService::Authenticate"]
35
+ CR["TokensService::Create"]
36
+ RF["TokensService::Refresh"]
37
+ RV["TokensService::Revoke"]
38
+ end
39
+
40
+ subgraph "Response layer"
41
+ TR["Responses::TokenResponse"]
42
+ ER["Responses::ErrorResponse"]
43
+ end
44
+
45
+ subgraph "Persistence"
46
+ TK["Devise::Api::Token (AR model)<br/>devise_api_tokens table"]
47
+ end
48
+
49
+ CFG[("Devise.api.config<br/>Dry::Configurable")]
50
+
51
+ RT --> MAP --> TC
52
+ TC --> SI & SU & RF & RV
53
+ SI --> AU
54
+ SI --> CR
55
+ SU --> CR
56
+ RF --> CR
57
+ TC --> TR & ER
58
+ HLP --> TK
59
+ TC --> HLP
60
+ CR & RV --> TK
61
+ RM -- "has_many :access_tokens" --> TK
62
+ INIT -.configures.-> CFG
63
+ CFG -.read at use time by.-> TC & TK & HLP & TR & ER & CR
64
+ ```
65
+
66
+ ## Boot / integration sequence
67
+
68
+ What happens when a host app uses the gem — the "magic" is concentrated in `lib/devise/api.rb`:
69
+
70
+ 1. `require 'devise/api'` (via Bundler) loads configuration, responses, helpers, and generator.
71
+ 2. `Devise.api = Devise::Api::Configuration.new` installs the global config.
72
+ 3. `Devise::Models::Api` is defined: an `ActiveSupport::Concern` that adds `has_many :access_tokens` (polymorphic `resource_owner`, `dependent: :destroy`) and the class method `supported_devise_modules` (a `devise_modules.inquiry` used throughout to feature-detect `trackable?` / `lockable?` / `confirmable?`).
73
+ 4. `Devise.add_module :api, strategy: false, controller: :tokens, route: { api: %i[revoke refresh sign_up sign_in info] }` registers the module with Devise. `strategy: false` means **no Warden strategy is registered** — authentication of API requests is done exclusively by the controller helpers, not by Warden middleware.
74
+ 5. `ActiveSupport.on_load(:action_controller)` includes `Devise::Api::Controllers::Helpers` into **every** controller, so `authenticate_devise_api_token!`, `current_devise_api_token`, and `current_devise_api_user` are available anywhere.
75
+ 6. `Devise::Api::Rails::Engine` (an isolated engine) makes `app/controllers` and `app/services` autoloadable.
76
+ 7. `lib/devise/api/rails/routes.rb` reopens `ActionDispatch::Routing::Mapper` to define `#devise_api`, which Devise's `devise_for` dispatches to (because of the `route:` key in step 4). It draws, under `/<scope>/tokens` (path segment and controller both overridable):
77
+ - `POST sign_up`, `POST sign_in`, `POST refresh`, `POST revoke`, `GET info`
78
+
79
+ ## Request lifecycle
80
+
81
+ Every `TokensController` action follows the same template — **callback → service → response**:
82
+
83
+ ```mermaid
84
+ sequenceDiagram
85
+ participant C as Client
86
+ participant TC as TokensController
87
+ participant CB as Config callbacks
88
+ participant S as Service (dry-monads)
89
+ participant R as TokenResponse / ErrorResponse
90
+
91
+ C->>TC: POST /users/tokens/sign_in
92
+ TC->>CB: before_sign_in.call(params, request, resource_class)
93
+ TC->>S: ResourceOwnerService::SignIn.new(params:, resource_class:).call
94
+ alt Success(token)
95
+ S-->>TC: Success(Devise::Api::Token)
96
+ TC->>TC: call_devise_trackable! (if trackable)
97
+ TC->>R: TokenResponse.new(request, token:, action: :sign_in)
98
+ TC->>CB: after_successful_sign_in.call(owner, token, request)
99
+ TC-->>C: 200 { token, refresh_token, expires_in, token_type, resource_owner }
100
+ else Failure(hash)
101
+ S-->>TC: Failure(error: :invalid_authentication, record: user)
102
+ TC->>R: ErrorResponse.new(request, resource_class:, **failure)
103
+ TC-->>C: 401/400/422 { error, error_description, lockable?, confirmable? }
104
+ end
105
+ ```
106
+
107
+ Key details:
108
+
109
+ - The controller inherits from `Devise.api.config.base_controller.constantize` (default `::DeviseController`), so Devise's `resource_class` (derived from the route scope, e.g. `devise_for :users` → `User`) is available for free. CSRF verification is skipped; `wrap_parameters false`.
110
+ - Services return `Success(token)` or `Failure(hash)` where the hash is `{ error: <symbol>, record: <model or nil> }` — splatted directly into `ErrorResponse.new`. The error symbol must exist in `ErrorResponse::ERROR_TYPES` and have a locale entry.
111
+ - Only `info` runs `authenticate_devise_api_token!`. `revoke` and `refresh` look up the token themselves and respond based on what they find (`revoke` with an unknown token still returns `204`).
112
+ - `refresh` looks tokens up **by `refresh_token` column**, everything else by `access_token` column — same extraction logic (`find_devise_api_token`), different lookup.
113
+
114
+ ## Token extraction
115
+
116
+ `Controllers::Helpers#find_devise_api_token` honors `Devise.api.config.authorization.location`:
117
+
118
+ - `:header` — `request.headers['Authorization']`, stripping the `'Bearer '` scheme prefix (both header name and scheme configurable)
119
+ - `:params` — `params['access_token']` (key configurable)
120
+ - `:both` (default) — params first, then header
121
+
122
+ ## Directory layout (engine-loaded vs require-loaded)
123
+
124
+ ```
125
+ app/ # autoloaded via the engine
126
+ controllers/devise/api/tokens_controller.rb
127
+ services/devise/api/ # BaseService + 2 service namespaces
128
+ lib/devise/api.rb # entry point: module registration, global config
129
+ lib/devise/api/
130
+ configuration.rb # Dry::Configurable settings tree
131
+ token.rb # ActiveRecord model (required at load, NOT autoloaded)
132
+ controllers/helpers.rb # mixed into ActionController
133
+ responses/{token,error}_response.rb # plain Ruby response builders
134
+ rails/{engine,routes}.rb
135
+ generators/install_generator.rb # rails g devise_api:install
136
+ generators/templates/migration.rb.erb
137
+ config/locales/en.yml # all user-facing strings (devise.api.error_response.*)
138
+ spec/dummy/ # full Rails 8 host app used by the test suite
139
+ ```
140
+
141
+ ## Dependency notes
142
+
143
+ - **dry-rb stack** (`dry-configurable`, `dry-initializer`, `dry-monads`, `dry-types`): configuration + service objects. `BaseService` defines the shared `Types` module (`include Dry.Types()`).
144
+ - **devise** `>= 4.7.2`: model registration, `find_for_authentication`, `valid_password?`, `friendly_token`, lockable/confirmable/trackable integration.
145
+ - **rails** `>= 6.0`, Ruby `>= 2.7`.
@@ -0,0 +1,84 @@
1
+ # Configuration Reference
2
+
3
+ All settings live on the single global `Devise.api.config`, a `Dry::Configurable` tree defined in `lib/devise/api/configuration.rb`. Host apps configure it inside `Devise.setup`:
4
+
5
+ ```ruby
6
+ # config/initializers/devise.rb
7
+ Devise.setup do |config|
8
+ config.api.configure do |api|
9
+ api.access_token.expires_in = 30.minutes
10
+ end
11
+ end
12
+ ```
13
+
14
+ Settings are read **at use time**, never cached at boot — changing them (e.g. in a test) takes effect immediately.
15
+
16
+ ## `access_token`
17
+
18
+ | Setting | Default | Type | Consumed by |
19
+ |---|---|---|---|
20
+ | `expires_in` | `1.hour` | `ActiveSupport::Duration` | `TokensService::Create` (stored per-row as integer seconds), `Token#expired?` via stored `expires_in` |
21
+ | `expires_in_infinite` | `proc { \|owner\| false }` | proc → bool | `Token#expired?` (skips expiry check) and the `expires_in` presence validation |
22
+ | `generator` | `proc { \|owner\| Devise.friendly_token(60) }` | proc → String | `Token.generate_uniq_access_token` (looped until unique) |
23
+
24
+ ## `refresh_token`
25
+
26
+ | Setting | Default | Type | Consumed by |
27
+ |---|---|---|---|
28
+ | `enabled` | `true` | bool | refresh action gate, `refresh_token` presence validation, `TokenResponse` (omits field), `Token.generate_uniq_refresh_token` (returns `nil` when disabled) |
29
+ | `expires_in` | `1.week` | Duration | `Token#refresh_token_expired?` — computed from `created_at`, **not stored per-row** (a config change retroactively affects existing tokens) |
30
+ | `expires_in_infinite` | `proc { \|owner\| false }` | proc → bool | `Token#refresh_token_expired?` |
31
+ | `generator` | `proc { \|owner\| Devise.friendly_token(60) }` | proc → String | `Token.generate_uniq_refresh_token` |
32
+ | `rotation_enabled` | `false` | bool | `TokensService::Refresh` (revokes the presented token in the same transaction as minting the new one) and the `refresh` action's reuse detection (a rotated/revoked refresh token presented again triggers `Token#revoke_family!`) |
33
+
34
+ ## `sign_up`
35
+
36
+ | Setting | Default | Consumed by |
37
+ |---|---|---|
38
+ | `enabled` | `true` | `sign_up` action gate (→ `sign_up_disabled` error) |
39
+ | `extra_fields` | `[]` | permitted sign-up params **and** extra keys in the `resource_owner` response object (both directions!) |
40
+
41
+ ## `error_response`
42
+
43
+ | Setting | Default | Consumed by |
44
+ |---|---|---|
45
+ | `verbose_account_state` | `true` | `ErrorResponse` — when `false`, the `lockable`/`confirmable` metadata blocks are omitted from error bodies (the locked/unconfirmed `error_description` specialization is kept) |
46
+
47
+ ## `paranoid`
48
+
49
+ | Setting | Default | Consumed by |
50
+ |---|---|---|
51
+ | `paranoid` | `false` | `Authenticate` (unknown account returns `invalid_authentication` instead of `invalid_email`/`invalid_login`) and `ErrorResponse` (always the generic description, never lockable/confirmable details). Mirrors Devise's `config.paranoid`: makes existent and non-existent accounts indistinguishable to callers. |
52
+
53
+ ## `authorization`
54
+
55
+ | Setting | Default | Consumed by |
56
+ |---|---|---|
57
+ | `key` | `'Authorization'` | header name read by `extract_devise_api_token_from_headers` |
58
+ | `scheme` | `'Bearer'` | prefix stripped from the header; echoed as `token_type` in responses |
59
+ | `location` | `:both` | `:header`, `:params`, or `:both` (params win); anything else raises `ArgumentError` |
60
+ | `params_key` | `'access_token'` | param name read by `extract_devise_api_token_from_params` |
61
+
62
+ ## Base classes (string names, `constantize`d at use sites)
63
+
64
+ | Setting | Default | Notes |
65
+ |---|---|---|
66
+ | `base_token_model` | `'Devise::Api::Token'` | Used for the `has_many :access_tokens` class name, all token lookups, association class names inside `Token` itself, and uniqueness checks in the generators. Subclass `Devise::Api::Token` and point this at it. |
67
+ | `base_controller` | `'::DeviseController'` | Superclass of `TokensController`, resolved **at class-definition time** — must be set before the controller is first loaded. |
68
+
69
+ ## Lifecycle callbacks
70
+
71
+ All default to no-op procs. Called by `TokensController` around each action:
72
+
73
+ | Setting | Signature | Timing |
74
+ |---|---|---|
75
+ | `before_sign_in` | `(params, request, resource_class)` | before `SignIn` service |
76
+ | `before_sign_up` | `(params, request, resource_class)` | after the enabled-gate, before `SignUp` service |
77
+ | `before_refresh` | `(token, request)` | after token found & not revoked, before `Refresh` service |
78
+ | `before_revoke` | `(token, request)` | before `Revoke` service (token may be `nil`) |
79
+ | `after_successful_sign_in` | `(resource_owner, token, request)` | after success, before render |
80
+ | `after_successful_sign_up` | `(resource_owner, token, request)` | after success, before render |
81
+ | `after_successful_refresh` | `(resource_owner, token, request)` | after success, before render |
82
+ | `after_successful_revoke` | `(resource_owner_or_nil, token, request)` | after success, before render |
83
+
84
+ `before_*` return values are ignored — they cannot halt the request (raise if you need to abort, or use a controller `before_action` in a subclassed controller).
@@ -0,0 +1,80 @@
1
+ # Data Model
2
+
3
+ One table, one model: `Devise::Api::Token` (`lib/devise/api/token.rb`) backed by `devise_api_tokens`. The resource owner (User, Customer, …) is polymorphic, so multiple Devise models share the table.
4
+
5
+ ## Schema
6
+
7
+ From the generator template (`lib/devise/api/generators/templates/migration.rb.erb`); primary/foreign key types follow the host app's generator config (UUID-safe):
8
+
9
+ | Column | Type | Null | Index | Notes |
10
+ |---|---|---|---|---|
11
+ | `resource_owner_type` / `resource_owner_id` | string / fk | no | composite | polymorphic owner |
12
+ | `access_token` | string | no | yes, **unique** | opaque token, stored **in plaintext** |
13
+ | `refresh_token` | string | yes | yes, **unique** | `nil` when refresh disabled |
14
+ | `expires_in` | integer | no | — | access-token TTL in seconds, snapshotted at creation |
15
+ | `revoked_at` | datetime | yes | — | non-nil ⇒ revoked |
16
+ | `previous_refresh_token` | string | yes | yes | links a token to the refresh token it was minted from |
17
+ | `created_at` / `updated_at` | datetime | no | — | expiry math is based on `created_at` |
18
+
19
+ Uniqueness is enforced in three layers: unique DB indexes on `access_token`/`refresh_token` (the backstop), ActiveRecord uniqueness validations, and generate-and-retry loops (`generate_uniq_access_token` / `generate_uniq_refresh_token`). If a concurrent insert still hits the index, `TokensService::Create` rescues `ActiveRecord::RecordNotUnique` and retries with a fresh token (up to 3 attempts). Installs created before the unique indexes shipped should add them via migration (see CHANGELOG).
20
+
21
+ ## Entity relationships
22
+
23
+ ```mermaid
24
+ erDiagram
25
+ RESOURCE_OWNER ||--o{ DEVISE_API_TOKEN : "has_many :access_tokens (dependent: destroy)"
26
+ DEVISE_API_TOKEN |o--o{ DEVISE_API_TOKEN : "previous_refresh -> refreshes"
27
+
28
+ DEVISE_API_TOKEN {
29
+ string access_token
30
+ string refresh_token
31
+ int expires_in
32
+ datetime revoked_at
33
+ string previous_refresh_token
34
+ }
35
+ ```
36
+
37
+ The self-reference is keyed on token *strings*, not ids: `belongs_to :previous_refresh` joins `previous_refresh_token → refresh_token`; `has_many :refreshes` is the inverse. A refresh chain is therefore walkable in both directions.
38
+
39
+ ## Token lifecycle
40
+
41
+ ```mermaid
42
+ stateDiagram-v2
43
+ [*] --> active : sign_in / sign_up / refresh (TokensService::Create)
44
+ active --> expired : now > created_at + expires_in
45
+ active --> revoked : revoke (revoked_at set)
46
+ expired --> refreshable : refresh_token still valid
47
+ note right of refreshable
48
+ refresh mints a NEW row with
49
+ previous_refresh_token = old refresh_token.
50
+ Default: the old row is NOT revoked or deleted.
51
+ With rotation_enabled: the old row is revoked.
52
+ end note
53
+ refreshable --> [*]
54
+ ```
55
+
56
+ Predicates on `Token`:
57
+
58
+ - `expired?` — `Time.current > created_at + expires_in.seconds`, unless `access_token.expires_in_infinite.(owner)`. The per-row `expires_in` snapshot means config changes only affect *new* tokens.
59
+ - `refresh_token_expired?` — `Time.current > created_at + Devise.api.config.refresh_token.expires_in.seconds`, unless infinite. **Not** snapshotted — reads current config, so changing `refresh_token.expires_in` retroactively re-times existing tokens.
60
+ - `revoked?` — `revoked_at.present?`. `active?` = not expired and not revoked.
61
+
62
+ Mutators: `revoke!` (stamps `revoked_at`, idempotent) and `revoke_family!` (walks to the chain root via `previous_refresh`, then revokes the root and every descendant via `refreshes` — used by refresh-token reuse detection).
63
+
64
+ The model also filters `access_token`/`refresh_token`/`previous_refresh_token` from `#inspect` via `filter_attributes`, and the engine adds the same keys to the host app's `filter_parameters` (request-log redaction). Raw SQL logging can still print token values.
65
+
66
+ ## Refresh semantics (important)
67
+
68
+ `TokensService::Refresh`:
69
+
70
+ 1. Controller finds the row by `refresh_token` column; rejects unknown (`invalid_refresh_token`, 400) or revoked (`revoked_token`, 401) tokens.
71
+ 2. With `refresh_token.rotation_enabled`, a presented token that is revoked **or already has `refreshes`** is treated as reuse: the whole family is revoked (`revoke_family!`) and `revoked_token` is returned.
72
+ 3. Service rejects if `refresh_token_expired?` (`expired_refresh_token`).
73
+ 4. Otherwise mints a **new** token row (`previous_refresh_token` = the presented refresh token) and returns it. With rotation enabled, the presented token is revoked in the same transaction.
74
+ 5. **Without rotation (the default)** the old row keeps its state: its access token stays usable until its own expiry, and its refresh token **can be presented again** until the refresh-token TTL passes — see [analysis/security-review.md](analysis/security-review.md) (SEC-2, resolved via the opt-in flag).
75
+
76
+ Revoking (`TokensService::Revoke`) only stamps `revoked_at` on the *presented access token's row* — not the whole chain, and not other sessions.
77
+
78
+ ## Cleanup
79
+
80
+ Nothing prunes dead rows. Expired/revoked tokens accumulate until the resource owner is destroyed (`dependent: :destroy`). Host apps that care should add their own sweep job (candidate future feature).
@@ -0,0 +1,52 @@
1
+ # Development Guide
2
+
3
+ ## Setup
4
+
5
+ ```bash
6
+ bin/setup # bundle install (+ any local setup)
7
+ bin/console # IRB with the gem loaded
8
+ ```
9
+
10
+ Requirements: Ruby >= 2.7 (CI tests 2.7–3.2), Bundler. The test suite needs no external services (sqlite3 in-repo dummy app).
11
+
12
+ ## Everyday commands
13
+
14
+ ```bash
15
+ bundle exec rake # DEFAULT task = rspec + rubocop; the pre-push check
16
+ bundle exec rake rspec # tests only
17
+ bundle exec rubocop # lint only (or: bundle exec rubocop -a for safe autocorrect)
18
+ bundle exec rubocop --config .rubocop.yml --parallel # exactly what CI runs
19
+ ```
20
+
21
+ Style highlights (`.rubocop.yml`): target Ruby 2.7, single quotes, 120-char lines, `Style/Documentation` off, method/ABC limits at 30. The codebase currently has no `rubocop:disable` comments — prefer refactoring (extracted helpers, constants) over adding them.
22
+
23
+ ## Repo conventions
24
+
25
+ - Every Ruby file starts with `# frozen_string_literal: true`.
26
+ - Library code resolves the token model and controller through `Devise.api.config` — see the invariants in [architecture.md](architecture.md).
27
+ - User-facing strings live in `config/locales/en.yml` only (keys under `devise.api.error_response`). The install generator copies this file into host apps as `devise_api.en.yml` — additions are picked up by new installs automatically, but existing apps keep their copied version.
28
+ - Commit style in history: conventional-commit-ish prefixes (`feat:`, `fix:`, `docs:`) with PR number suffix, e.g. `fix: nil memoization (#48)`.
29
+
30
+ ## Versioning & release
31
+
32
+ - Version constant: `lib/devise/api/version.rb` (currently `0.2.0`). SemVer intent; still pre-1.0 so minor bumps may break.
33
+ - `CHANGELOG.md` exists but has not been maintained past the initial release — update it as part of any release work.
34
+ - Release flow (maintainer): bump `version.rb` → update CHANGELOG → `bundle exec rake release` (tags, pushes, publishes to rubygems.org). Gem files are `git ls-files` minus `bin|test|spec|features` (see gemspec) — nothing in `spec/dummy` ships.
35
+
36
+ ## CI
37
+
38
+ Two workflows on every push, matrix over Ruby 3.2 / 3.3 / 3.4 / 4.0 (the development `Gemfile.lock` resolves to Rails 8.x, which needs Ruby >= 3.2; the gemspec still allows Ruby >= 2.7 for host apps):
39
+
40
+ - `test.yml` — `bundle install` + `bundle exec rake rspec`
41
+ - `rubocop.yml` — `bundle exec rubocop --config .rubocop.yml --parallel`
42
+
43
+ SimpleCov coverage runs with the suite (95% line minimum enforced on full-suite runs — see [testing.md](testing.md)). No scheduled builds, no release automation.
44
+
45
+ ## Pointers for common change types
46
+
47
+ | Change | Touch points |
48
+ |---|---|
49
+ | New endpoint action | `rails/routes.rb`, `Devise.add_module` route list in `lib/devise/api.rb`, controller, service, `TokenResponse::ACTIONS`, request specs, [api-reference.md](api-reference.md) |
50
+ | New error type | service, `ErrorResponse::ERROR_TYPES` + `#status` mapping, `config/locales/en.yml`, specs, [api-reference.md](api-reference.md) |
51
+ | New config setting | `configuration.rb`, use site(s), `configuration_spec.rb`, README config block, [configuration.md](configuration.md) |
52
+ | Schema change | generator template, dummy migrations + schema, `Token` model, upgrade migration guidance, [data-model.md](data-model.md) |
data/docs/extending.md ADDED
@@ -0,0 +1,75 @@
1
+ # Extending & Customization Points
2
+
3
+ Supported ways for a host app to change behavior, ordered from lightest to heaviest. Everything here is exercised by the test suite or README examples — treat as public API.
4
+
5
+ ## 1. Configuration
6
+
7
+ Most behavior is a config knob — TTLs, generators, header/param names, callbacks, sign-up fields. See [configuration.md](configuration.md). The `before_*` / `after_successful_*` procs cover most "hook into the flow" needs without subclassing.
8
+
9
+ ## 2. Custom routes / controller per scope
10
+
11
+ ```ruby
12
+ devise_for :customers,
13
+ controllers: { tokens: 'customers/api/tokens' }, # controller override
14
+ path: 'accounts' # path scope override
15
+ ```
16
+
17
+ The `:tokens` key is read by `Mapper#devise_api` (`lib/devise/api/rails/routes.rb`). The path segment itself comes from `mapping.path_names.fetch(:tokens, 'tokens')`, so `devise_for :users, path_names: { tokens: 'sessions' }` renames the `/tokens` segment. Covered by `spec/routing/customized_routes_spec.rb`.
18
+
19
+ The custom controller should subclass `Devise::Api::TokensController` (the dummy app's `spec/dummy/app/controllers/devise/api/customized_tokens_controller.rb` is the reference example).
20
+
21
+ ## 3. Response decorators
22
+
23
+ Response classes are plain Ruby — `prepend` a module to reshape payloads:
24
+
25
+ ```ruby
26
+ module Devise::Api::Responses::TokenResponseDecorator
27
+ def body
28
+ default_body.merge(roles: resource_owner.roles)
29
+ end
30
+ end
31
+
32
+ Devise::Api::Responses::TokenResponse.prepend Devise::Api::Responses::TokenResponseDecorator
33
+ ```
34
+
35
+ Same pattern works for `ErrorResponse`. Useful methods to override: `body`, `default_body`, `default_resource_owner`, `status`.
36
+
37
+ ## 4. Custom token model
38
+
39
+ Subclass and point config at it (as a **string**):
40
+
41
+ ```ruby
42
+ class ApiToken < Devise::Api::Token
43
+ # scopes, extra columns, etc.
44
+ end
45
+
46
+ Devise.setup do |config|
47
+ config.api.configure { |api| api.base_token_model = 'ApiToken' }
48
+ end
49
+ ```
50
+
51
+ Every lookup, association, and generator in the gem resolves through `Devise.api.config.base_token_model.constantize`, so the subclass is used everywhere consistently.
52
+
53
+ ## 5. Custom base controller
54
+
55
+ `api.base_controller = 'Api::BaseController'` changes what `TokensController` inherits from. Caveat: the superclass is resolved when `TokensController` is first loaded, so set it in the Devise initializer (before any request). The base must provide Devise's controller API (`resource_class` etc.) — subclassing `DeviseController` or including its behavior is the safe route.
56
+
57
+ ## 6. Custom services in the host app
58
+
59
+ `Devise::Api::BaseService` is designed to be inherited by host-app services (dry-initializer options + dry-monads). See README "Using devise base services". This is additive — the gem never calls host services.
60
+
61
+ ## Protected-endpoint integration (the common case)
62
+
63
+ ```ruby
64
+ class Api::V1::BaseController < ActionController::Base
65
+ skip_before_action :verify_authenticity_token, raise: false
66
+ before_action :authenticate_devise_api_token!
67
+ end
68
+ ```
69
+
70
+ Helpers available in **every** controller (mixed in on load): `authenticate_devise_api_token!`, `current_devise_api_token`, `current_devise_api_user`, `current_devise_api_refresh_token`.
71
+
72
+ ## What is NOT a supported extension point
73
+
74
+ - Reaching into `Devise::Api::Token` by constant from library code (breaks `base_token_model` substitution).
75
+ - Per-model/per-scope configuration — config is global; two Devise scopes share TTLs, callbacks, and token model. If you need per-scope behavior, branch inside the callback procs (they receive `resource_class`/`resource_owner`).
data/docs/services.md ADDED
@@ -0,0 +1,72 @@
1
+ # Service Layer Contracts
2
+
3
+ All services live in `app/services/devise/api/` and inherit `Devise::Api::BaseService`, which wires:
4
+
5
+ - **dry-initializer** — declare inputs with `option :name, type: Types::X`
6
+ - **dry-monads** — `Success` / `Failure` returns, do-notation (`yield another_service_call` unwraps a `Success` or short-circuits the whole `call` with the inner `Failure`)
7
+ - a shared `Types` module (`include Dry.Types()`)
8
+
9
+ ## Universal contract
10
+
11
+ ```
12
+ service = SomeService.new(**options).call
13
+ service.success? → service.success # a Devise::Api::Token (or resource owner for Authenticate)
14
+ service.failure? → service.failure # { error: Symbol, record: <AR model or nil> }
15
+ ```
16
+
17
+ The failure hash is splatted straight into `ErrorResponse.new(request, resource_class:, **failure)`, so:
18
+ - `error:` **must** be a symbol from `ErrorResponse::ERROR_TYPES` with a locale entry (see [api-reference.md](api-reference.md)).
19
+ - `record:` (optional) supplies validation messages and lockable/confirmable context.
20
+
21
+ ## Composition graph
22
+
23
+ ```mermaid
24
+ graph LR
25
+ SignIn --> Authenticate
26
+ SignIn --> Create
27
+ SignUp --> Create
28
+ Refresh --> Create
29
+ Revoke
30
+ ```
31
+
32
+ ## ResourceOwnerService
33
+
34
+ ### `Authenticate`
35
+ - **Inputs:** `params: Types::Hash`, `resource_class: Types::Class`
36
+ - **Logic:** `find_for_authentication(params.slice(*authentication_keys))` → `valid_for_authentication? { valid_password? }` (increments `failed_attempts` for lockable) → `active_for_authentication?` (fails for locked/unconfirmed).
37
+ - **Success:** the resource owner. **Failures:** no record → `:invalid_email` when `:email` is an authentication key, `:invalid_login` otherwise, or `:invalid_authentication` when `paranoid` is enabled (all with `record: nil`); wrong password / inactive account → `:invalid_authentication` (record attached).
38
+
39
+ ### `SignIn`
40
+ - **Inputs:** `params`, `resource_class`
41
+ - **Logic:** `Authenticate` → `TokensService::Create`; resets lockable failed attempts on success.
42
+ - **Success:** the new `Devise::Api::Token`. **Failures:** propagated from children.
43
+
44
+ ### `SignUp`
45
+ - **Inputs:** `params`, `resource_class`
46
+ - **Logic:** inside a DB transaction: `resource_class.new(params).save` → `TokensService::Create`.
47
+ - **Success:** the new token. **Failures:** `:resource_owner_create_error` (record attached) or propagated. The transaction works because do-notation's `yield` raises `Dry::Monads::Do::Halt` on a `Failure` — the exception unwinds through the `transaction` block (rolling back the already-saved user if token creation fails) before the `Do` wrapper converts it back into the `Failure` return value.
48
+
49
+ ### `TokensService`
50
+
51
+ ### `Create`
52
+ - **Inputs:** `resource_owner` (untyped), `previous_refresh_token: String | Nil = nil`
53
+ - **Logic:** guards `resource_owner.respond_to?(:access_tokens)` → builds row with generated unique access/refresh tokens, `expires_in` snapshot from config, `previous_refresh_token` passthrough. Rescues `ActiveRecord::RecordNotUnique` from the unique token indexes and retries with freshly generated tokens (`MAX_TOKEN_GENERATION_ATTEMPTS = 3`), then re-raises.
54
+ - **Success:** the token. **Failures:** `:invalid_resource_owner`, `:devise_api_token_create_error`.
55
+
56
+ ### `Refresh`
57
+ - **Inputs:** `devise_api_token` (typed `Types.Instance(<base_token_model>)` — resolved at class load), `resource_owner` (defaults to the token's owner)
58
+ - **Logic:** reject if `refresh_token_expired?` → `Create` with `previous_refresh_token` set. With `refresh_token.rotation_enabled`, the new token is minted and the presented token revoked in one transaction (a `Create` failure rolls back and leaves the presented token untouched); otherwise the old token is **not** revoked.
59
+ - **Failures:** `:expired_refresh_token` or propagated.
60
+
61
+ ### `Revoke`
62
+ - **Inputs:** `devise_api_token` (optional — may be `nil`)
63
+ - **Logic:** blank token → `Success(nil)`; already revoked/expired → `Success(token)` (idempotent); else stamp `revoked_at = Time.current`.
64
+ - **Failures:** `:devise_api_token_revoke_error`.
65
+
66
+ ## Conventions for new services
67
+
68
+ 1. Inherit `Devise::Api::BaseService`; declare inputs with `option` + `Types`.
69
+ 2. Return `Success(value)` / `Failure(error: :symbol, record: model_or_nil)` — never raise for expected outcomes.
70
+ 3. New error symbols require: `ERROR_TYPES` entry, status mapping in `ErrorResponse#status`, locale string, api-reference row.
71
+ 4. Compose via do-notation (`yield`), not manual `if service.success?` nesting.
72
+ 5. Cover behavior with both request specs and service unit specs asserting the monad contract (see `spec/services/**`).
data/docs/testing.md ADDED
@@ -0,0 +1,65 @@
1
+ # Testing Guide
2
+
3
+ ## Commands
4
+
5
+ ```bash
6
+ bundle exec rake # rspec + rubocop — what CI runs; run before finishing any change
7
+ bundle exec rake rspec # full suite
8
+ bundle exec rspec spec/requests/tokens_spec.rb # one file
9
+ bundle exec rspec spec/requests/tokens_spec.rb:95 # one example/context by line
10
+ bundle exec rspec --only-failures # uses .rspec_status
11
+ ```
12
+
13
+ CI (`.github/workflows/test.yml`, `rubocop.yml`) runs on push across Ruby 3.2 / 3.3 / 3.4 / 4.0.
14
+
15
+ ## Coverage
16
+
17
+ SimpleCov runs automatically with every spec run (started at the top of `spec/spec_helper.rb`, before the gem
18
+ is required); the HTML report lands in `coverage/index.html` and the summary in `coverage/.last_run.json`.
19
+ A **95% line-coverage minimum** is enforced whenever `CI` or `ENFORCE_COVERAGE` is set — `bundle exec rake`
20
+ sets `ENFORCE_COVERAGE` via the `enforce_coverage` prerequisite task, so full-suite runs (local and CI) fail
21
+ below the bar while single-file `bundle exec rspec` runs stay unaffected. Branch coverage is reported but not
22
+ enforced. `lib/devise/api/version.rb` is filtered because the gemspec loads it before SimpleCov can start.
23
+
24
+ ## How the suite is wired
25
+
26
+ - `spec/spec_helper.rb` sets `RAILS_ENV=test`, starts SimpleCov, requires the gem, then boots the **dummy Rails app** at `spec/dummy` (`require 'dummy/config/environment'`) — a real Rails 8 app with sqlite3 whose `User` model enables `database_authenticatable, registerable, recoverable, rememberable, validatable, confirmable, lockable, trackable, :api` (schema: `spec/dummy/db/schema.rb`). A second bare model, `AdminUser` (`database_authenticatable, registerable, validatable, :api` only), exists to exercise the "optional Devise module not enabled" branches (non-trackable sign-in, error/token responses without `lockable`/`confirmable` info); it has its own `devise_for :admin_users` routes.
27
+ - `DatabaseCleaner` wraps every example; spec types are inferred from file location; monkey-patching is disabled (`RSpec.describe` only).
28
+ - `spec/supports/` is auto-required: FactoryBot setup, ActiveRecord config, and two request-spec helpers:
29
+ - `authentication_headers_for(owner, token = nil, token_type = :access_token)` → `{ Authorization: "Bearer …" }` (creates a token via FactoryBot when none given; pass `:refresh_token` to authenticate refresh calls)
30
+ - `parsed_body` → `JSON.parse(response.body, object_class: OpenStruct)` (assert with `parsed_body.error`, etc.)
31
+
32
+ ## Factories (`spec/factories/`)
33
+
34
+ - `:user` — Faker email/password.
35
+ - `:admin_user` — Faker email/password (bare model without optional Devise modules).
36
+ - `:devise_api_token` — random hex tokens, `expires_in: 1.hour`, associated `:user`. Traits: `:access_token_expired` (backdates `created_at` 2h), `:refresh_token_expired` (2 months), `:revoked`.
37
+
38
+ Note the traits work by **backdating `created_at`** because all expiry math derives from it — keep that in mind when adding time-sensitive specs (or use `travel_to`).
39
+
40
+ ## Where coverage lives (map)
41
+
42
+ | Area | Spec | State |
43
+ |---|---|---|
44
+ | All 5 endpoints × valid/invalid/expired/revoked × header/param | `spec/requests/tokens_spec.rb` (~700 lines) | ✅ primary coverage |
45
+ | Endpoints for a bare model (no trackable/lockable/confirmable) | `spec/requests/admin_user_tokens_spec.rb` | ✅ |
46
+ | `authenticate_devise_api_token!` on a host controller | `spec/requests/authentication_spec.rb` (via dummy `HomeController`) | ✅ |
47
+ | Non-default config (disabled sign_up/refresh, extra_fields, `:header`/`:params`-only location, revoke failure) | `spec/requests/configuration_overrides_spec.rb` | ✅ |
48
+ | Refresh-token rotation + family-revocation reuse detection (`rotation_enabled`) | `spec/requests/refresh_token_rotation_spec.rb` | ✅ |
49
+ | Enumeration hardening (`paranoid`, `error_response.verbose_account_state`) | `spec/requests/paranoid_mode_spec.rb` | ✅ |
50
+ | Engine initializer (`filter_parameters`) | `spec/devise/api/engine_spec.rb` | ✅ |
51
+ | Default + customized routes (`controllers:`, `path:`, `path_names:` overrides) | `spec/routing/*.rb` | ✅ |
52
+ | Config defaults + overrides on fresh instances | `spec/devise/api/configuration_spec.rb` | ✅ |
53
+ | Response classes (incl. locked/unconfirmed/bare-model variants, disabled refresh, extra_fields) | `spec/devise/api/responses/*_spec.rb` | ✅ |
54
+ | **Service objects** (monad contract: `Success`/`Failure` per branch) | `spec/services/**` | ✅ |
55
+ | Token model (`active?`, expiry incl. `expires_in_infinite`, generator collision retry, conditional validations, `revoke!`/`revoke_family!`, unique-index backstop, `#inspect` redaction) | `spec/devise/api/token_spec.rb` | ✅ |
56
+ | Controller helpers (extraction rescue, invalid location `ArgumentError`, memoized refresh-token lookup) | `spec/devise/api/controllers/helpers_spec.rb` | ✅ |
57
+ | Generator (migration template, locale copy) | `spec/devise/api/generators/install_generator_spec.rb` | ✅ |
58
+
59
+ ## Conventions for new specs
60
+
61
+ 1. **Behavior → request spec.** Follow the existing pattern in `tokens_spec.rb`: one `describe` per endpoint, `context` per scenario, assert status + `parsed_body` fields + DB side effects (`Devise::Api::Token.count`, `revoked_at`).
62
+ 2. When filling in service specs, test the monad contract: `.call` returns `Success(token)` / `Failure(error:, record:)` for each branch documented in [services.md](services.md).
63
+ 3. Config-dependent specs must restore config after (the global `Devise.api.config` leaks between examples — wrap changes in `around` blocks or reset in `after`).
64
+ 4. Routing specs that redraw routes must restore them (see `customized_routes_spec.rb` `after :all` reload pattern).
65
+ 5. RuboCop: `Metrics/BlockLength` is excluded for `spec/**/*_spec.rb` — long request specs are fine.
@@ -18,6 +18,7 @@ module Devise
18
18
  setting :expires_in, default: 1.week, reader: true
19
19
  setting :generator, default: proc { |_resource_owner| ::Devise.friendly_token(60) }, reader: true
20
20
  setting :expires_in_infinite, default: proc { |_resource_owner| false }, reader: true
21
+ setting :rotation_enabled, default: false, reader: true
21
22
  end
22
23
 
23
24
  setting :sign_up, reader: true do
@@ -32,6 +33,12 @@ module Devise
32
33
  setting :params_key, default: 'access_token', reader: true
33
34
  end
34
35
 
36
+ setting :error_response, reader: true do
37
+ setting :verbose_account_state, default: true, reader: true
38
+ end
39
+
40
+ setting :paranoid, default: false, reader: true
41
+
35
42
  setting :base_token_model, default: 'Devise::Api::Token', reader: true
36
43
  setting :base_controller, default: '::DeviseController', reader: true
37
44
 
@@ -32,13 +32,15 @@ module Devise
32
32
  end
33
33
 
34
34
  def current_devise_api_refresh_token
35
- token = find_devise_api_token
35
+ return @current_devise_api_refresh_token if defined?(@current_devise_api_refresh_token)
36
36
 
37
- Devise.api.config.base_token_model.constantize.find_by(refresh_token: token)
37
+ token = find_devise_api_token
38
+ devise_api_token_model = Devise.api.config.base_token_model.constantize
39
+ @current_devise_api_refresh_token = devise_api_token_model.find_by(refresh_token: token)
38
40
  end
39
41
 
40
42
  def current_devise_api_token
41
- return @current_devise_api_token if @current_devise_api_token
43
+ return @current_devise_api_token if defined?(@current_devise_api_token)
42
44
 
43
45
  token = find_devise_api_token
44
46
  devise_api_token_model = Devise.api.config.base_token_model.constantize
@@ -7,8 +7,8 @@ class CreateDeviseApiTables < ActiveRecord::Migration<%= migration_version %>
7
7
 
8
8
  create_table :devise_api_tokens, id: primary_key_type do |t|
9
9
  t.belongs_to :resource_owner, null: false, polymorphic: true, index: true, type: foreign_key_type
10
- t.string :access_token, null: false, index: true
11
- t.string :refresh_token, null: true, index: true
10
+ t.string :access_token, null: false, index: { unique: true }
11
+ t.string :refresh_token, null: true, index: { unique: true }
12
12
  t.integer :expires_in, null: false
13
13
  t.datetime :revoked_at, null: true
14
14
  t.string :previous_refresh_token, null: true, index: true
@@ -5,6 +5,12 @@ module Devise
5
5
  module Rails
6
6
  class Engine < ::Rails::Engine
7
7
  isolate_namespace Devise::Api
8
+
9
+ # Keep raw token secrets out of the host app's request logs (tokens can arrive as
10
+ # query/body params when authorization.location is :params or :both)
11
+ initializer 'devise.api.filter_parameters' do |app|
12
+ app.config.filter_parameters |= %i[access_token refresh_token previous_refresh_token]
13
+ end
8
14
  end
9
15
  end
10
16
  end