jwt_auth_engine 1.0.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 +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE.txt +21 -0
- data/README.md +422 -0
- data/app/controllers/concerns/jwt_auth_engine/authenticatable.rb +52 -0
- data/app/controllers/concerns/jwt_auth_engine/rescuable.rb +23 -0
- data/app/controllers/concerns/jwt_auth_engine/response_renderable.rb +29 -0
- data/app/controllers/concerns/jwt_auth_engine/serializable.rb +21 -0
- data/app/controllers/concerns/jwt_auth_engine/tokenizable.rb +22 -0
- data/app/controllers/jwt_auth_engine/application_controller.rb +21 -0
- data/app/controllers/jwt_auth_engine/passwords_controller.rb +26 -0
- data/app/controllers/jwt_auth_engine/ping_controller.rb +21 -0
- data/app/controllers/jwt_auth_engine/profiles_controller.rb +15 -0
- data/app/controllers/jwt_auth_engine/registrations_controller.rb +33 -0
- data/app/controllers/jwt_auth_engine/sessions_controller.rb +41 -0
- data/app/controllers/jwt_auth_engine/tokens_controller.rb +21 -0
- data/app/services/jwt_auth_engine/base_service.rb +26 -0
- data/app/services/jwt_auth_engine/change_password_service.rb +48 -0
- data/app/services/jwt_auth_engine/login_service.rb +46 -0
- data/app/services/jwt_auth_engine/refresh_token_service.rb +35 -0
- data/app/services/jwt_auth_engine/signup_service.rb +23 -0
- data/app/services/jwt_auth_engine/token_service.rb +72 -0
- data/config/routes.rb +47 -0
- data/lib/generators/jwt_auth_engine/install/install_generator.rb +95 -0
- data/lib/generators/jwt_auth_engine/install/templates/add_jwt_auth_engine_columns_migration.rb.tt +11 -0
- data/lib/generators/jwt_auth_engine/install/templates/auth_model_concern.rb.tt +32 -0
- data/lib/generators/jwt_auth_engine/install/templates/jwt_auth_engine_initializer.rb.tt +22 -0
- data/lib/jwt_auth_engine/configuration.rb +50 -0
- data/lib/jwt_auth_engine/constants.rb +16 -0
- data/lib/jwt_auth_engine/engine.rb +10 -0
- data/lib/jwt_auth_engine/errors.rb +14 -0
- data/lib/jwt_auth_engine/version.rb +5 -0
- data/lib/jwt_auth_engine.rb +79 -0
- data/sig/jwt_auth_engine.rbs +4 -0
- metadata +137 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b8a125215a3bf059193a53e3da267d0f1a184fb4bf2fd1164496cdbd50f19771
|
|
4
|
+
data.tar.gz: 9bb10ed91224204e9cb0b56ba11233e3eb7ec4a3899f34568cae4d5bd77356f7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 462499366a8d8eeb8c4b80e5d6c912eeb869d944a6dd101cfceb2a6b5fef66803a40431b346f5271eb558c43f3a65e1cbab21ef142a4cc6f19b70404a860fdf7
|
|
7
|
+
data.tar.gz: fbc43a2bacb4add9e4c600705fcb50e4be24dc768b7327c03abbf1760f68eb9b654c0896e8dafbec66a39e740f76a8497891f7dbdaa49f6b764a500f9b180e33
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## [1.0.0] - 2026-06-28
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Initial release of JwtAuthEngine
|
|
18
|
+
- Stateless JWT access + refresh token pair issuance
|
|
19
|
+
- Configurable auth model (`auth_model`), identifier field, and password field
|
|
20
|
+
- Install generator with migration, initializer, and model concern templates
|
|
21
|
+
- Endpoints: `signup`, `login`, `logout`, `refresh_token`, `change_password`, `me`, `ping`, `authenticated_ping`
|
|
22
|
+
- Service layer: `SignupService`, `LoginService`, `RefreshTokenService`, `ChangePasswordService`, `TokenService`
|
|
23
|
+
- Controller concerns: `Authenticatable`, `Rescuable`, `ResponseRenderable`, `Serializable`, `Tokenizable`
|
|
24
|
+
- Case-insensitive identifier lookup for string/text columns
|
|
25
|
+
- Password field type validation before update
|
|
26
|
+
- Global exception handling scoped to engine endpoints only
|
|
27
|
+
- Dynamic JWT payload keys based on configured model and identifier
|
|
28
|
+
- `JwtAuthEngine::MissingSecretKey` guard before any API call
|
|
29
|
+
- Comprehensive compatibility testing infrastructure:
|
|
30
|
+
- Appraisal boundary strategy covering Rails 6.1-8.1 (12 boundary versions)
|
|
31
|
+
- `bin/appraisal_rvm` helper for RVM-isolated gemset testing
|
|
32
|
+
- Two-tier CI workflows: fast required checks + scheduled full matrix
|
|
33
|
+
- GitHub Actions workflows for automated compatibility testing
|
|
34
|
+
- Rails 6.1 boot compatibility fix (explicit `require 'logger'`)
|
|
35
|
+
- Rails 8.x sqlite3 2.1+ support with conditional gem version override
|
|
36
|
+
- Complete test coverage (95% line, 90% branch) with SimpleCov enforcement
|
|
37
|
+
- RSpec test suite with 175 examples across all endpoints and services
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abhishek Kushwaha
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
# JwtAuthEngine
|
|
2
|
+
|
|
3
|
+
[](https://www.ruby-lang.org)
|
|
4
|
+
[](https://rubyonrails.org)
|
|
5
|
+
[](https://github.com/abhiskush/jwt_auth_engine/actions/workflows/ci.yml)
|
|
6
|
+
[](LICENSE.txt)
|
|
7
|
+
|
|
8
|
+
A mountable Rails engine that provides **stateless JWT authentication** endpoints for API-only Rails applications. It authenticates against your host application's existing model — no separate user table required.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Table of Contents
|
|
13
|
+
|
|
14
|
+
- [Features](#features)
|
|
15
|
+
- [Requirements](#requirements)
|
|
16
|
+
- [Installation](#installation)
|
|
17
|
+
- [Configuration](#configuration)
|
|
18
|
+
- [Mounting the Engine](#mounting-the-engine)
|
|
19
|
+
- [API Endpoints](#api-endpoints)
|
|
20
|
+
- [Authentication](#authentication)
|
|
21
|
+
- [Customization](#customization)
|
|
22
|
+
- [Response Shapes](#response-shapes)
|
|
23
|
+
- [Testing](#testing)
|
|
24
|
+
- [Contributing](#contributing)
|
|
25
|
+
- [License](#license)
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- 🔐 Stateless JWT access & refresh token pair
|
|
32
|
+
- 🧩 Plugs into any ActiveRecord model (`User`, `Account`, `Admin::User`, etc.)
|
|
33
|
+
- ⚙️ Fully configurable identifier field (`email`, `username`, etc.)
|
|
34
|
+
- ⚙️ Fully configurable password field (`password`, `pin`, etc.)
|
|
35
|
+
- 🏗️ Install generator that scaffolds migration, initializer, and model concern
|
|
36
|
+
- 🔒 Case-insensitive identifier lookup for string/text columns
|
|
37
|
+
- 🛡️ Global exception handling scoped only to engine endpoints
|
|
38
|
+
- 📦 Zero host-app pollution — uses `isolate_namespace`
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Requirements
|
|
43
|
+
|
|
44
|
+
| Dependency | Version |
|
|
45
|
+
|------------|-------------|
|
|
46
|
+
| Ruby | `>= 3.0.0` |
|
|
47
|
+
| Rails | `>= 6.1.0` |
|
|
48
|
+
| bcrypt | `>= 3.1.18` |
|
|
49
|
+
| jwt | `>= 2.9.0` |
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Installation
|
|
54
|
+
|
|
55
|
+
### 1. Add the gem
|
|
56
|
+
|
|
57
|
+
Add this line to your application's Gemfile:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
gem 'jwt_auth_engine'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 2. Install dependencies
|
|
64
|
+
|
|
65
|
+
```zsh
|
|
66
|
+
bundle install
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### 3. Run the install generator
|
|
70
|
+
|
|
71
|
+
```zsh
|
|
72
|
+
bundle exec rails generate jwt_auth_engine:install
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The generator accepts options to customize the auth model, identifier field, and password field. See [Generator Options](#generator-options).
|
|
76
|
+
|
|
77
|
+
This generates:
|
|
78
|
+
|
|
79
|
+
| File | Purpose |
|
|
80
|
+
|--------------------------------------------------------------------|-----------------------------------------------------|
|
|
81
|
+
| `config/initializers/jwt_auth_engine.rb` | Engine configuration |
|
|
82
|
+
| `db/migrate/<timestamp>_add_jwt_auth_engine_columns_to_<table>.rb` | Adds identifier + password digest columns |
|
|
83
|
+
| `app/models/concerns/jwt_auth_engine/auth_model_concern.rb` | Model behavior (`has_secure_password`, validations) |
|
|
84
|
+
|
|
85
|
+
The generator also attempts to inject `include JwtAuthEngine::AuthModelConcern` into your model file automatically.
|
|
86
|
+
|
|
87
|
+
### 4. Run migration
|
|
88
|
+
|
|
89
|
+
```zsh
|
|
90
|
+
bundle exec rails db:migrate
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 5. Set your JWT secret key
|
|
94
|
+
|
|
95
|
+
Open the generated initializer and set `config.jwt_secret_key` to a secure random string.
|
|
96
|
+
|
|
97
|
+
> ⚠️ The engine will raise `JwtAuthEngine::MissingSecretKey` at runtime if this value is `nil`.
|
|
98
|
+
|
|
99
|
+
Generate a secret:
|
|
100
|
+
|
|
101
|
+
```zsh
|
|
102
|
+
bundle exec rails secret
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
You can source it any way that suits your setup:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
# From Rails credentials
|
|
109
|
+
config.jwt_secret_key = Rails.application.credentials.dig(:jwt_auth_engine, :secret_key)
|
|
110
|
+
|
|
111
|
+
# From an environment variable
|
|
112
|
+
config.jwt_secret_key = ENV['JWT_SECRET_KEY']
|
|
113
|
+
|
|
114
|
+
# From Rails secrets
|
|
115
|
+
config.jwt_secret_key = Rails.application.secrets.jwt_secret_key
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Configuration
|
|
121
|
+
|
|
122
|
+
The install generator creates an initializer with sensible defaults:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
# config/initializers/jwt_auth_engine.rb
|
|
126
|
+
JwtAuthEngine.configure do |config|
|
|
127
|
+
config.auth_model = 'User' # Your ActiveRecord model class name
|
|
128
|
+
config.identifier_field = :email # Field used for login/signup lookup
|
|
129
|
+
config.password_field = :password # Base attribute for has_secure_password
|
|
130
|
+
config.jwt_secret_key = nil # REQUIRED: Set to a secure random string
|
|
131
|
+
config.access_token_expiry = 8.hours # Access token expiry duration
|
|
132
|
+
config.refresh_token_expiry = 7.days # Refresh token expiry duration
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Generator Options
|
|
137
|
+
|
|
138
|
+
All options are optional — defaults are applied if not provided.
|
|
139
|
+
E.g., to use an `Account` model with `username` as the identifier and `pin` as the password field:
|
|
140
|
+
|
|
141
|
+
```zsh
|
|
142
|
+
bundle exec rails generate jwt_auth_engine:install \
|
|
143
|
+
--auth-model=Account \
|
|
144
|
+
--identifier-field=username \
|
|
145
|
+
--password-field=pin
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
| Option | Default | Description |
|
|
149
|
+
|----------------------|------------|-----------------------------------------------------------------------------|
|
|
150
|
+
| `--auth-model` | `User` | ActiveRecord model class name |
|
|
151
|
+
| `--identifier-field` | `email` | Login/signup lookup field |
|
|
152
|
+
| `--password-field` | `password` | Password attribute (digest column = `<field>_digest`, required by `bcrypt`) |
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Mounting the Engine
|
|
157
|
+
|
|
158
|
+
Add to your host app's routes:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
# config/routes.rb
|
|
162
|
+
Rails.application.routes.draw do
|
|
163
|
+
# other routes...
|
|
164
|
+
mount JwtAuthEngine::Engine, at: '/auth'
|
|
165
|
+
end
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
All engine endpoints will be available under `/auth/` path.
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## API Endpoints
|
|
173
|
+
|
|
174
|
+
You may see [JWT Auth Engine API Collection](https://abhiskush.github.io/jwt_auth_engine/api_collection.html) for detailed information on endpoints, request/response shapes, and example payloads.
|
|
175
|
+
|
|
176
|
+
A summary of endpoints is provided below.
|
|
177
|
+
|
|
178
|
+
### Public Endpoints
|
|
179
|
+
|
|
180
|
+
| Method | Path | Description |
|
|
181
|
+
|--------|----------------|-------------------------------------------------------------------|
|
|
182
|
+
| `GET` | `/auth/ping` | Health check — returns `pong!` |
|
|
183
|
+
| `POST` | `/auth/signup` | Register a new account and returns new access and refresh tokens |
|
|
184
|
+
| `POST` | `/auth/login` | Authenticate and returns new access and refresh tokens |
|
|
185
|
+
|
|
186
|
+
### Protected Endpoints (require access token)
|
|
187
|
+
|
|
188
|
+
| Method | Path | Description |
|
|
189
|
+
|----------|----------------------------|-------------------------------------------|
|
|
190
|
+
| `GET` | `/auth/authenticated_ping` | Verify token is valid |
|
|
191
|
+
| `DELETE` | `/auth/logout` | Stateless logout (client discards tokens) |
|
|
192
|
+
| `POST` | `/auth/change_password` | Change password for authenticated account |
|
|
193
|
+
| `GET` | `/auth/me` | Retrieve authenticated account profile |
|
|
194
|
+
|
|
195
|
+
### Token Endpoints (require refresh token)
|
|
196
|
+
|
|
197
|
+
| Method | Path | Description |
|
|
198
|
+
|--------|-----------------------|---------------------------------------------|
|
|
199
|
+
| `POST` | `/auth/refresh_token` | Exchange refresh token for a new token pair |
|
|
200
|
+
|
|
201
|
+
> Send the refresh token as `Authorization: Bearer <refresh_token>` — not the access token.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## Authentication
|
|
206
|
+
|
|
207
|
+
### How It Works
|
|
208
|
+
|
|
209
|
+
1. On login/signup, the engine issues a **token pair** (access + refresh)
|
|
210
|
+
2. The client sends the access token as `Authorization: Bearer <token>` on protected requests
|
|
211
|
+
3. The engine decodes the token, extracts the model ID, and loads the authenticated record
|
|
212
|
+
4. When the access token expires, use the refresh token at `/auth/refresh_token` to get a new pair
|
|
213
|
+
|
|
214
|
+
### JWT Payload Structure
|
|
215
|
+
|
|
216
|
+
```json
|
|
217
|
+
{
|
|
218
|
+
"user_id": 1,
|
|
219
|
+
"email": "user@example.com",
|
|
220
|
+
"exp": 1717200000,
|
|
221
|
+
"token_type": "access"
|
|
222
|
+
}
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Payload keys are dynamic: `user_id` becomes `account_id` if `auth_model = 'Account'` and `email` becomes `username` if `identifier_field = :username`.
|
|
226
|
+
|
|
227
|
+
### Token Expiry Defaults
|
|
228
|
+
|
|
229
|
+
| Token | Default Expiry |
|
|
230
|
+
|---------------|----------------|
|
|
231
|
+
| Access token | 8 hours |
|
|
232
|
+
| Refresh token | 7 days |
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## Customization
|
|
237
|
+
|
|
238
|
+
### Auth Model Concern
|
|
239
|
+
|
|
240
|
+
The generator creates `app/models/concerns/jwt_auth_engine/auth_model_concern.rb` in your host app. This file is **yours to customize**:
|
|
241
|
+
|
|
242
|
+
- Modify validations
|
|
243
|
+
- Change normalization behavior
|
|
244
|
+
- Add additional callbacks
|
|
245
|
+
|
|
246
|
+
If the generator can't find your model file, include the concern manually:
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
class User < ApplicationRecord
|
|
250
|
+
include JwtAuthEngine::AuthModelConcern
|
|
251
|
+
end
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
The generated concern includes a `before_validation` callback that strips and downcases string identifiers. You can customize or remove this in the generated concern file to match your identifier semantics.
|
|
255
|
+
|
|
256
|
+
---
|
|
257
|
+
|
|
258
|
+
## Response Shapes
|
|
259
|
+
|
|
260
|
+
All engine responses follow a consistent JSON shape, scoped only to engine endpoints (no impact on host app error handling).
|
|
261
|
+
|
|
262
|
+
| HTTP Status | Shape |
|
|
263
|
+
|--------------|----------------------------------------------------------------------------|
|
|
264
|
+
| `200` | `{ "success": true, "data": { ... } }` |
|
|
265
|
+
| `201` | `{ "success": true, "data": { ... } }` |
|
|
266
|
+
| `401` | `{ "success": false, "errors": "..." }` |
|
|
267
|
+
| `422` | `{ "success": false, "errors": [...] }` |
|
|
268
|
+
| `500` | `{ "success": false, "errors": { "message": "...", "backtrace": [...] } }` |
|
|
269
|
+
|
|
270
|
+
## Custom Error Classes
|
|
271
|
+
|
|
272
|
+
| Error | When Raised |
|
|
273
|
+
|------------------------------------|--------------------------------------------------------|
|
|
274
|
+
| `JwtAuthEngine::MissingSecretKey` | `jwt_secret_key` is not configured |
|
|
275
|
+
| `JwtAuthEngine::AuthModelNotFound` | Configured model class cannot be resolved |
|
|
276
|
+
| `JwtAuthEngine::InvalidAuthModel` | Model does not inherit from `ActiveRecord::Base` |
|
|
277
|
+
| `JwtAuthEngine::TokenExpired` | JWT has passed its expiry time |
|
|
278
|
+
| `JwtAuthEngine::InvalidToken` | JWT cannot be decoded |
|
|
279
|
+
| `JwtAuthEngine::InvalidTokenType` | Token type does not match expected (access vs refresh) |
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## Testing
|
|
284
|
+
|
|
285
|
+
The gem uses **RSpec** for testing and **SimpleCov** for code coverage enforcement. Tests run against a minimal dummy Rails app (`spec/dummy/`) with an in-memory SQLite database.
|
|
286
|
+
|
|
287
|
+
### Coverage Thresholds
|
|
288
|
+
|
|
289
|
+
| Metric | Minimum Required |
|
|
290
|
+
|--------|-----------------|
|
|
291
|
+
| Line coverage | 95% |
|
|
292
|
+
| Branch coverage | 90% |
|
|
293
|
+
|
|
294
|
+
SimpleCov will fail the test suite if coverage drops below these thresholds.
|
|
295
|
+
|
|
296
|
+
### Running Tests
|
|
297
|
+
|
|
298
|
+
```zsh
|
|
299
|
+
# Run the full test suite
|
|
300
|
+
bundle exec rspec
|
|
301
|
+
|
|
302
|
+
# Run with verbose output
|
|
303
|
+
bundle exec rspec --format documentation
|
|
304
|
+
|
|
305
|
+
# Run a specific spec file
|
|
306
|
+
bundle exec rspec spec/services/jwt_auth_engine/login_service_spec.rb
|
|
307
|
+
|
|
308
|
+
# Run a specific example by line number
|
|
309
|
+
bundle exec rspec spec/requests/jwt_auth_engine/sessions_spec.rb:12
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
After each run, a coverage report is generated at `coverage/index.html`.
|
|
313
|
+
|
|
314
|
+
### Test Structure
|
|
315
|
+
|
|
316
|
+
```
|
|
317
|
+
spec/
|
|
318
|
+
├── spec_helper.rb # SimpleCov setup + coverage thresholds
|
|
319
|
+
├── rails_helper.rb # Boots dummy app, DatabaseCleaner, JwtAuthEngine config
|
|
320
|
+
├── dummy/ # Minimal Rails API app for integration testing
|
|
321
|
+
├── lib/ # Unit tests for core library modules
|
|
322
|
+
├── services/ # Unit tests for service objects
|
|
323
|
+
└── requests/ # Integration tests for all API endpoints
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### Multi-Version Testing with Appraisal
|
|
327
|
+
|
|
328
|
+
The gem supports Rails 6.1 through 8.1. We use
|
|
329
|
+
[Appraisal](https://github.com/thoughtbot/appraisal) with a boundary strategy:
|
|
330
|
+
minimum and latest patch for each supported Rails minor line.
|
|
331
|
+
|
|
332
|
+
#### Setup
|
|
333
|
+
|
|
334
|
+
```zsh
|
|
335
|
+
# Install base dependencies
|
|
336
|
+
bundle install
|
|
337
|
+
|
|
338
|
+
# Generate appraisal gemfiles (run after editing Appraisals file)
|
|
339
|
+
bundle exec appraisal generate
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
#### Listing Available Appraisals
|
|
343
|
+
|
|
344
|
+
```zsh
|
|
345
|
+
bundle exec appraisal list
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
#### Running Tests Against a Specific Boundary
|
|
349
|
+
|
|
350
|
+
```zsh
|
|
351
|
+
bundle exec appraisal rails-6.1-min rspec
|
|
352
|
+
bundle exec appraisal rails-7.2-max rspec
|
|
353
|
+
bundle exec appraisal rails-8.1-max rspec
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
#### Running Tests Against ALL Rails Versions
|
|
357
|
+
|
|
358
|
+
```zsh
|
|
359
|
+
bundle exec appraisal rspec
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
#### RVM Isolated Gemsets (One Per Appraisal)
|
|
363
|
+
|
|
364
|
+
If you use RVM, this project includes a helper that keeps dependencies isolated
|
|
365
|
+
with one gemset per appraisal gemfile.
|
|
366
|
+
|
|
367
|
+
```zsh
|
|
368
|
+
# Show all appraisal targets
|
|
369
|
+
bin/appraisal_rvm list
|
|
370
|
+
|
|
371
|
+
# Create Ruby + gemset + install dependencies for one appraisal
|
|
372
|
+
bin/appraisal_rvm bootstrap rails-7.2-max
|
|
373
|
+
|
|
374
|
+
# Run specs for one appraisal in its dedicated gemset
|
|
375
|
+
bin/appraisal_rvm run rails-7.2-max
|
|
376
|
+
|
|
377
|
+
# Pre-install all appraisals (can take time)
|
|
378
|
+
bin/appraisal_rvm bootstrap-all
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
> **Note:** Rails and Ruby compatibility is constrained by both Rails requirements
|
|
382
|
+
> and this gem's minimum Ruby requirement (`>= 3.0.0`).
|
|
383
|
+
|
|
384
|
+
#### Compatibility Matrix
|
|
385
|
+
|
|
386
|
+
| Rails Line | Tested Boundaries | Effective Min Ruby |
|
|
387
|
+
|---|---|---|
|
|
388
|
+
| Rails 6.1.x | 6.1.0 and 6.1.7.10 | 3.0.0 |
|
|
389
|
+
| Rails 7.0.x | 7.0.0 and 7.0.10 | 3.0.0 |
|
|
390
|
+
| Rails 7.1.x | 7.1.0 and 7.1.6 | 3.0.0 |
|
|
391
|
+
| Rails 7.2.x | 7.2.0 and 7.2.3.1 | 3.1.0 |
|
|
392
|
+
| Rails 8.0.x | 8.0.0 and 8.0.5 | 3.2.0 |
|
|
393
|
+
| Rails 8.1.x | 8.1.0 and 8.1.3 | 3.2.0 |
|
|
394
|
+
|
|
395
|
+
### CI (GitHub Actions)
|
|
396
|
+
|
|
397
|
+
We run a two-tier matrix:
|
|
398
|
+
|
|
399
|
+
- `.github/workflows/ci.yml`: required PR/push checks with boundary coverage.
|
|
400
|
+
- `.github/workflows/compatibility-full.yml`: scheduled/manual broad matrix
|
|
401
|
+
across Ruby 3.0-3.5 and all Rails boundary appraisals.
|
|
402
|
+
|
|
403
|
+
This is a standard professional setup: fast required feedback plus deeper
|
|
404
|
+
periodic compatibility surveillance.
|
|
405
|
+
|
|
406
|
+
---
|
|
407
|
+
|
|
408
|
+
## Contributing
|
|
409
|
+
|
|
410
|
+
1. Fork the repository
|
|
411
|
+
2. Create your feature branch (`git checkout -b feature/my-feature`)
|
|
412
|
+
3. Commit your changes (`git commit -am 'Add my feature'`)
|
|
413
|
+
4. Push to the branch (`git push origin feature/my-feature`)
|
|
414
|
+
5. Open a Pull Request
|
|
415
|
+
|
|
416
|
+
Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on the code of conduct and contribution process.
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## License
|
|
421
|
+
|
|
422
|
+
This gem is available as open source under the terms of the [MIT License](LICENSE.txt).
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
# Controller concern for extracting and validating bearer access tokens.
|
|
5
|
+
module Authenticatable
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
attr_accessor :current_auth_model_instance
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def authenticate_auth_model!
|
|
15
|
+
token = bearer_token
|
|
16
|
+
return render_unauthorized('Authorization header missing or malformed') unless token
|
|
17
|
+
|
|
18
|
+
assign_current_auth_model(token)
|
|
19
|
+
rescue StandardError => e
|
|
20
|
+
handle_authentication_error(e)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def assign_current_auth_model(token)
|
|
24
|
+
payload = TokenService.decode_access(token)
|
|
25
|
+
payload_key = JwtAuthEngine.auth_model_token_payload_key
|
|
26
|
+
model_id = payload[payload_key]
|
|
27
|
+
self.current_auth_model_instance = JwtAuthEngine.auth_model_class.find(model_id)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def handle_authentication_error(error)
|
|
31
|
+
message = authentication_error_message(error)
|
|
32
|
+
return render_unauthorized(message) if message
|
|
33
|
+
|
|
34
|
+
raise error
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def authentication_error_message(error)
|
|
38
|
+
return 'Access token has expired' if error.is_a?(JwtAuthEngine::TokenExpired)
|
|
39
|
+
return error.message if error.is_a?(JwtAuthEngine::InvalidToken)
|
|
40
|
+
return 'Access token expected' if error.is_a?(JwtAuthEngine::InvalidTokenType)
|
|
41
|
+
|
|
42
|
+
"#{JwtAuthEngine.auth_model_name.to_s.humanize} not found" if error.is_a?(ActiveRecord::RecordNotFound)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def bearer_token
|
|
46
|
+
header = request.headers['Authorization']
|
|
47
|
+
return nil unless header&.start_with?('Bearer ')
|
|
48
|
+
|
|
49
|
+
header.split(' ', 2).last.presence
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
# Concern that converts unhandled exceptions to structured JSON responses.
|
|
5
|
+
module Rescuable
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
rescue_from StandardError, with: :render_unhandled_exception
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def render_unhandled_exception(exception)
|
|
15
|
+
render_internal_server_error(
|
|
16
|
+
errors: {
|
|
17
|
+
message: exception.message,
|
|
18
|
+
backtrace: Array(exception.backtrace).first(10)
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
# Concern with shared JSON response helpers for engine controllers.
|
|
5
|
+
module ResponseRenderable
|
|
6
|
+
def render_success(**data)
|
|
7
|
+
render json: { success: true, data: data.except(:status) },
|
|
8
|
+
status: data[:status] || :ok
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def render_unauthorized(message = 'Unauthorized')
|
|
12
|
+
render_error(errors: message, status: :unauthorized)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def render_validation_error(errors:)
|
|
16
|
+
render_error(errors: errors, status: :unprocessable_entity)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def render_internal_server_error(errors:)
|
|
20
|
+
render_error(errors: errors, status: :internal_server_error)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def render_error(errors:, status:)
|
|
26
|
+
render json: { success: false, errors: errors }, status: status
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
# Concern that serializes auth model fields for API responses.
|
|
5
|
+
module Serializable
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def serialize_auth_model_instance(auth_model_instance)
|
|
11
|
+
if auth_model_instance.respond_to?(JwtAuthEngine.identifier_field)
|
|
12
|
+
identifier_value = auth_model_instance.public_send(JwtAuthEngine.identifier_field)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
primary_key = auth_model_instance.class.primary_key
|
|
16
|
+
primary_key_value = auth_model_instance.public_send(primary_key)
|
|
17
|
+
|
|
18
|
+
{ primary_key => primary_key_value, JwtAuthEngine.identifier_field => identifier_value }.compact
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
# Concern that builds token payloads and issues access/refresh token pairs.
|
|
5
|
+
module Tokenizable
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def issue_tokens(auth_model_instance)
|
|
11
|
+
payload = {
|
|
12
|
+
JwtAuthEngine.auth_model_token_payload_key => auth_model_instance.id,
|
|
13
|
+
JwtAuthEngine.identifier_field => auth_model_instance.public_send(JwtAuthEngine.identifier_field)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
access_token: TokenService.encode_access(payload),
|
|
18
|
+
refresh_token: TokenService.encode_refresh(payload)
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
# Base API controller for all engine endpoints.
|
|
5
|
+
class ApplicationController < ActionController::API
|
|
6
|
+
include Authenticatable
|
|
7
|
+
include ResponseRenderable
|
|
8
|
+
include Rescuable
|
|
9
|
+
|
|
10
|
+
before_action :ensure_jwt_secret_key!
|
|
11
|
+
before_action :authenticate_auth_model!
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def ensure_jwt_secret_key!
|
|
16
|
+
JwtAuthEngine.configuration.jwt_secret_key
|
|
17
|
+
rescue JwtAuthEngine::MissingSecretKey => e
|
|
18
|
+
render_internal_server_error(errors: e.message)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JwtAuthEngine
|
|
4
|
+
# Handles password rotation for authenticated users.
|
|
5
|
+
class PasswordsController < ApplicationController
|
|
6
|
+
# ── POST /change_password ─────────────────────────────────────────────────
|
|
7
|
+
def change_password
|
|
8
|
+
result = ChangePasswordService.new(
|
|
9
|
+
auth_model_instance: current_auth_model_instance,
|
|
10
|
+
change_password_params: change_password_params
|
|
11
|
+
).call
|
|
12
|
+
|
|
13
|
+
return render_success(message: result[:message]) if result[:success]
|
|
14
|
+
|
|
15
|
+
return render_unauthorized(result[:invalid]) if result[:invalid]
|
|
16
|
+
|
|
17
|
+
render_validation_error(errors: result[:errors])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def change_password_params
|
|
23
|
+
params.permit(JwtAuthEngine.current_password_field, JwtAuthEngine.new_password_field)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|