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.
- 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 +54 -1
- data/CLAUDE.md +49 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +10 -1
- 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 -4
- data/sig/devise/api.rbs +0 -6
|
@@ -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 7 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).
|
data/docs/data-model.md
ADDED
|
@@ -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).
|
data/docs/development.md
ADDED
|
@@ -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 2.7.7 / 3.0.5 / 3.1.3 / 3.2.0:
|
|
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 2.7 / 3.0 / 3.1 / 3.2.
|
|
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 7 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
|
-
|
|
35
|
+
return @current_devise_api_refresh_token if defined?(@current_devise_api_refresh_token)
|
|
36
36
|
|
|
37
|
-
|
|
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
|