passkeys-rails 0.1.6 → 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +373 -35
- data/app/controllers/concerns/passkeys_rails/authentication.rb +3 -8
- data/app/controllers/passkeys_rails/passkeys_controller.rb +17 -3
- data/app/interactors/passkeys_rails/debug_login.rb +43 -0
- data/app/interactors/passkeys_rails/finish_registration.rb +18 -8
- data/app/models/concerns/passkeys_rails/authenticatable.rb +2 -2
- data/config/routes.rb +7 -0
- data/lib/generators/passkeys_rails/USAGE +1 -1
- data/lib/generators/passkeys_rails/install_generator.rb +9 -1
- data/lib/passkeys-rails.rb +32 -0
- data/lib/passkeys_rails/test/integration_helpers.rb +46 -0
- data/lib/passkeys_rails/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6d6b32ba5bcfa9553687bb70a01ba4e1b9211ab50336e62f3f7eae7033c3f689
|
4
|
+
data.tar.gz: e53cc11ffec79936ec74319fb1ec6d47cd74d67f4f56ff0c2d234abf8800b1f3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 87cb708335cfae465b142633dc829c92ac16c4d9d958807cbb682caf455ca18e2b13a24ad30b1bb3020f3493b6dd924151269eb37ba2eaeb09508d5b350a9572
|
7
|
+
data.tar.gz: 04fcd5259c6fe2b1d06f289254817fc1baee73e5d7c7d2aee3476a2155e02e2cb4c40c02deab4285a602bbe9c51806a3e90c5f15ed2b3de928ca9843b0f9fd14
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,21 @@
|
|
1
|
+
### 0.2.0
|
2
|
+
|
3
|
+
* Added passkeys/debug_login functionality.
|
4
|
+
|
5
|
+
### 0.1.7
|
6
|
+
|
7
|
+
* Added IntegrationHelpers to support client testing.
|
8
|
+
* Updated methods for interfacing with Rails client app.
|
9
|
+
* Changed route path added by the generator.
|
10
|
+
|
11
|
+
### 0.1.6
|
12
|
+
|
13
|
+
* Added default_class and class_whitelist config parameters.
|
14
|
+
|
15
|
+
### 0.1.5
|
16
|
+
|
17
|
+
* Updated validation to ensure the agent has completed registration to be considered valid.
|
18
|
+
|
1
19
|
### 0.1.4
|
2
20
|
|
3
21
|
* Changed namespace from Passkeys::Rails to PasskeysRails
|
data/README.md
CHANGED
@@ -1,43 +1,38 @@
|
|
1
|
-
[![Gem Version](https://badge.fury.io/rb/passkeys-rails.svg?cachebust=
|
1
|
+
[![Gem Version](https://badge.fury.io/rb/passkeys-rails.svg?cachebust=7)](https://badge.fury.io/rb/passkeys-rails)
|
2
2
|
[![Build Status](https://app.travis-ci.com/alliedcode/passkeys-rails.svg?branch=main)](https://travis-ci.org/alliedcode/passkeys-rails)
|
3
3
|
[![codecov](https://codecov.io/gh/alliedcode/passkeys-rails/branch/main/graph/badge.svg?token=UHSNJDUL21)](https://codecov.io/gh/alliedcode/passkeys-rails)
|
4
4
|
|
5
5
|
# PasskeysRails
|
6
|
-
|
7
|
-
|
6
|
+
|
7
|
+
Devise is awesome, but we don't need all that UI/UX for PassKeys, especially for an API back end.
|
8
|
+
|
9
|
+
The purpose of this gem is to make it easy to provide a rails back end API that supports PassKey authentication. It uses the [`webauthn`](https://github.com/w3c/webauthn) gem to do the cryptographic work and presents a simple API interface for passkey registration and authentication.
|
10
|
+
|
11
|
+
The target use case for this gem is a mobile application that uses a rails based API service to manage resources. The goal is to make it simple to register and authenticate users using passkeys from mobile applications in a rails API service.
|
12
|
+
|
8
13
|
|
9
14
|
## Usage
|
10
|
-
rails passkeys-rails::install
|
11
|
-
PasskeysRails maintains an Agent model and related Passeys. If you have a user model,
|
12
|
-
add `include PasskeysRails::Authenticatable` to your model and include the name of that
|
13
|
-
class (e.g. "User") in the authenticatable_class param when calling the register API.
|
14
15
|
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
in the finishRegistration API call.
|
16
|
+
**PasskeysRails** maintains an `Agent` model and related `Passkeys`. If you have a user model, add `include PasskeysRails::Authenticatable` to your model and include the name of that class (e.g. `"User"`) in the `authenticatable_class` param when calling the register API or set the `PasskeysRails.default_class` to the name of that class.
|
17
|
+
|
18
|
+
### Optionally providing a **"user"** model during registration
|
19
19
|
|
20
|
-
PasskeysRails
|
21
|
-
be created and provided an opportunity to do any required initialization at that time.
|
20
|
+
**PasskeysRails** does not require that you supply your own model, but it's often useful to do so. For example, if you have a User model that you would like to have created at registration, you can supply the model name in the `finishRegistration` API call.
|
22
21
|
|
23
|
-
|
22
|
+
**PasskeysRails** supports multiple `"user"` models. Whatever model name you supply will be created during a successful the `finishRegiration` API call. When created, it will be provided an opportunity to do any initialization at that time.
|
24
23
|
|
25
|
-
default_class and class_whitelist
|
24
|
+
There are two **PasskeysRails** configuration options related to this: `default_class` and `class_whitelist` - see below.
|
26
25
|
|
27
|
-
#### default_class
|
26
|
+
#### `default_class`
|
28
27
|
|
29
|
-
Configure default_class in passkeys_rails.rb
|
30
|
-
is provided in the API call. It is "User" by default. Since it's just a default, it can be overridden
|
31
|
-
in the API call for any other model. If no model is to be used, change it to nil.
|
28
|
+
Configure `default_class` in `passkeys_rails.rb`. Its value will be used during registration if none is provided in the API call. The default value is `"User"`. Since the `default_class` is just a default, it can be overridden in the `finishRegiration` API call to use a different model. If no model is to be used by default, set it to nil.
|
32
29
|
|
33
|
-
#### class_whitelist
|
30
|
+
#### `class_whitelist`
|
34
31
|
|
35
|
-
Configure class_whitelist in passkeys_rails.rb
|
36
|
-
If it is non-nil, it should be an array of class names that are allowed during registration. Supply an empty
|
37
|
-
array to prevent PasskeysRails from attempting to create anything other than its own PasskeysRails::Agent during
|
38
|
-
registration.
|
32
|
+
Configure `class_whitelist` in `passkeys_rails.rb`. The default value is `nil`. When `nil`, no whitelist will be applied. If it is non-nil, it should be an array of class names that are allowed during registration. Supply an empty array to prevent **PasskeysRails** from attempting to create anything other than its own `PasskeysRails::Agent` during registration.
|
39
33
|
|
40
34
|
## Installation
|
35
|
+
|
41
36
|
Add this line to your application's Gemfile:
|
42
37
|
|
43
38
|
```ruby
|
@@ -45,8 +40,9 @@ gem "passkeys_rails"
|
|
45
40
|
```
|
46
41
|
|
47
42
|
And then execute:
|
43
|
+
|
48
44
|
```bash
|
49
|
-
$ bundle
|
45
|
+
$ bundle install
|
50
46
|
```
|
51
47
|
|
52
48
|
Or install it yourself as:
|
@@ -54,22 +50,364 @@ Or install it yourself as:
|
|
54
50
|
$ gem install passkeys_rails
|
55
51
|
```
|
56
52
|
|
57
|
-
|
53
|
+
Finally, execute:
|
54
|
+
|
55
|
+
```bash
|
56
|
+
$ rails generate passkeys_rails:install
|
57
|
+
```
|
58
|
+
|
59
|
+
This will add the `passkeys_rails.rb` configuration file, passkeys routes, and a couple of database migrations to your project.
|
60
|
+
|
61
|
+
### Adding to an standard rails project
|
62
|
+
|
63
|
+
1. Add `before_action :authenticate_passkey!`
|
64
|
+
|
65
|
+
To prevent access to controller actions, add `before_action :authenticate_passkey!`. If an action is attempted without an authenticated entity, an error will be rendered in JSON with an :unauthorized result code.
|
66
|
+
|
67
|
+
1. Use `current_agent` and `current_agent.authenticatable`
|
68
|
+
|
69
|
+
To access the currently authenticated entity, use `current_agent`. If you associated the registration of the agent with one of your own models, use `current_agent.authenticatable`. For example, if you associated the `User` class with the registration, `current_agent.authenticatable` will be a User object.
|
70
|
+
|
71
|
+
1. Add `include PasskeysRails::Authenticatable` to model class(es)
|
72
|
+
|
73
|
+
If you have one or more classes that you want to use with authentication - e.g. a User class and an AdminUser class - add `include PasskeysRails::Authenticatable` to each of those classes. That adds a `registered?` method that you can call on your model to determine if they are registerd with your service, and a `registering_with(params)` method that you can override to initialize attributes of your model when it is created during registration. `params` is a hash with params passed to the API when registering. When called, your object has been built, but not yet saved. Upon return, **PasskeysRails** will attempt to save your object before finishing registration. If it is not valid, the registration will fail as well, returning the error error details to the caller.
|
74
|
+
|
75
|
+
### Adding to a Grape API rails project
|
76
|
+
|
77
|
+
1. Call `PasskeysRails.authenticate(request)` to authenticate the request.
|
78
|
+
|
79
|
+
Call `PasskeysRails.authenticate(request)` to get an object back that responds to `.success?` and `.failure?` as well as `.agent`, `.code`, and `.message`.
|
80
|
+
|
81
|
+
Alternatively, call `PasskeysRails.authenticate!(request)` from a helper in your base class. It will raise a `PasskeysRails.Error` exception if the caller isn't authenticated. You can catch the exception and render an appropriate error. The exception contains the error code and message.
|
82
|
+
|
83
|
+
1. Consider adding the following helpers to your base API class:
|
84
|
+
|
85
|
+
```ruby
|
86
|
+
helpers do
|
87
|
+
# Authenticate the request and cache the result
|
88
|
+
def passkey
|
89
|
+
@passkey ||= PasskeysRails.authenticate(request)
|
90
|
+
end
|
91
|
+
|
92
|
+
# Raise an exception if the request is not authentic
|
93
|
+
def authenticate_passkey!
|
94
|
+
error!({ code: passkey.code, message: passkey.message }, :unauthorized) if passkey.failure?
|
95
|
+
end
|
96
|
+
|
97
|
+
# Return the Passkeys::Agent if authentic, else return nil
|
98
|
+
def current_agent
|
99
|
+
passkey.agent
|
100
|
+
end
|
101
|
+
|
102
|
+
# If you have set authenticatable to be a User, you can use this to access the user from Grape endpoint methods
|
103
|
+
def current_user
|
104
|
+
user = current_agent&.authenticatable
|
105
|
+
user.is_a?(User) ? user : nil # sanity check to be sure authenticatable is a User
|
106
|
+
end
|
107
|
+
end
|
108
|
+
```
|
109
|
+
|
110
|
+
To prevent access to various endpoints, add `before_action :authenticate_passkey!` or call `authenticate_passkey!` from any method that requires authentication. If an action is attempted without an authenticated entity, an error will be rendered in JSON with an :unauthorized result code.
|
111
|
+
|
112
|
+
1. Use `current_agent` and `current_agent.authenticatable`
|
113
|
+
|
114
|
+
To access the currently authenticated entity, use `current_agent`. If you associated the registration of the agent with one of your own models, use `current_agent.authenticatable`. For example, if you associated the `User` class with the registration, `current_agent.authenticatable` will be a User object.
|
115
|
+
|
116
|
+
### Authentication Failure
|
117
|
+
|
118
|
+
1. In the event of authentication failure, PasskeysRails returns an error code and message.
|
119
|
+
|
120
|
+
1. In a standard rails controller, the error code and message are rendered in JSON if `before_action :authenticate_passkey!` fails.
|
58
121
|
|
59
|
-
|
122
|
+
1. In Grape, the error code and message are available in the result of the `PasskeysRails.authenticate(request)` method.
|
123
|
+
|
124
|
+
1. From standard rails controllers, you can also access `passkey_authentication_result` to get the code and message.
|
125
|
+
|
126
|
+
1. For `PasskeysRails.authenticate(request)` and `passkey_authentication_result`, the result is an object that respods to `.success?` and `.failure?`.
|
127
|
+
- When `.success?` is true (`.failure?` is false), the resources is authentic and it also responds to `.agent`, returning a PasskeysRails::Agent
|
128
|
+
- When `.success?` is false (`.failure?` is true), it responds to `.code` and `.message` to expose the error details.
|
129
|
+
- When `.code` is `:missing_token`, `.message` is **X-Auth header is required**, which means the caller didn't supply the auth header.
|
130
|
+
- When `.code` is `:invalid_token`, `.message` is **Invalid token - no agent exists with agent_id**, which means that the auth data is not valid.
|
131
|
+
- When `.code` is `:expired_token`, `.message` is **The token has expired**, which means that the token is valid, but expired, thuis it's not considered authentic.
|
132
|
+
- When `.code` is `:token_error`, `.message` is a description of the error. This is a catch-all in the event we are unable to decode the token.
|
133
|
+
|
134
|
+
In the future, the intention is to have the `.code` value stay consistent even if the `.message` changes. This also allows you to localize the messages as need using the code.
|
135
|
+
|
136
|
+
### Test Helpers
|
137
|
+
|
138
|
+
PasskeysRails includes some test helpers for integration tests. In order to use them, you need to include the module in your test cases/specs.
|
139
|
+
|
140
|
+
### Integration tests
|
141
|
+
|
142
|
+
Integration test helpers are available by including the `PasskeysRails::IntegrationHelpers` module.
|
143
|
+
|
144
|
+
```ruby
|
145
|
+
class PostTests < ActionDispatch::IntegrationTest
|
146
|
+
include PasskeysRails::Test::IntegrationHelpers
|
147
|
+
end
|
148
|
+
```
|
149
|
+
Now you can use the following `logged_in_headers` method in your integration tests.`
|
150
|
+
|
151
|
+
```ruby
|
152
|
+
test 'authenticated users can see posts' do
|
153
|
+
user = User.create
|
154
|
+
get '/posts', headers: logged_in_headers('username-123', user)
|
155
|
+
assert_response :success
|
156
|
+
end
|
157
|
+
```
|
158
|
+
|
159
|
+
RSpec can include the `IntegrationHelpers` module in their `:feature` and `:request` specs.
|
160
|
+
|
161
|
+
```ruby
|
162
|
+
RSpec.configure do |config|
|
163
|
+
config.include PasskeysRails::Test::IntegrationHelpers, type: :feature
|
164
|
+
config.include PasskeysRails::Test::IntegrationHelpers, type: :request
|
165
|
+
end
|
166
|
+
```
|
167
|
+
|
168
|
+
```ruby
|
169
|
+
RSpec.describe 'Posts', type: :request do
|
170
|
+
let(:user) { User.create }
|
171
|
+
it "allows authenticated users to see posts" do
|
172
|
+
get '/posts', headers: logged_in_headers('username-123', user)
|
173
|
+
expect(response).to be_success
|
174
|
+
end
|
175
|
+
end
|
176
|
+
```
|
177
|
+
|
178
|
+
### Mobile Application Integration
|
179
|
+
|
180
|
+
There are n groups of API endpoints that your mobile application may consume.
|
181
|
+
|
182
|
+
1. Unauthenticated (public) endpoints
|
183
|
+
1. Authenticated (private) endpoints
|
184
|
+
1. Passey endpoints (for supporting authentication)
|
185
|
+
|
186
|
+
**Unauthenticated endpoints** can be consumed without and authentication.
|
187
|
+
|
188
|
+
**Authenticated endpoints** are protected by `authenticate_passkey!` or `PasskeysRails.authenticate!(request)`. Those methods check for and validate the `X-Auth` header, which must be set to the auth token returned in the `AuthResponse`, described below.
|
189
|
+
|
190
|
+
**Passkey endpoints** are supplied by this gem and allow you to register a user, authenticate (login) a user, and refresh the token. This section describes these endpoints.
|
191
|
+
|
192
|
+
All Passkey endpoints accept and respond with JSON.
|
193
|
+
|
194
|
+
On **success**, they will respond with a 200 or 201 response code and relevant JSON.
|
195
|
+
|
196
|
+
On **error**, they will respond with a status code of `422` (Unprocessable Entity) and a JSON `ErrorResponse` structure:
|
197
|
+
|
198
|
+
```JSON
|
199
|
+
{
|
200
|
+
"error": {
|
201
|
+
"context": "authentication",
|
202
|
+
"code": "Specific text code",
|
203
|
+
"message": "Some human readable message"
|
204
|
+
}
|
205
|
+
}
|
206
|
+
```
|
207
|
+
|
208
|
+
Some endpoints return an `AuthResponse`, which has this JSON structure:
|
209
|
+
|
210
|
+
```JSON
|
211
|
+
{
|
212
|
+
"username": String, # the username used during registration
|
213
|
+
"auth_token": String # an expiring token to use to authenticate with the back end (X-Auth header)
|
214
|
+
}
|
215
|
+
```
|
60
216
|
|
61
|
-
|
217
|
+
#### POST /passkeys/challenge
|
62
218
|
|
63
|
-
|
219
|
+
Submit this to begin registration or authentication.
|
64
220
|
|
65
|
-
|
66
|
-
|
221
|
+
Supply a `{ "username": "unique username" } ` to register a new credential.
|
222
|
+
If all goes well, the JSON response will be the `options_for_create` from webauthn.
|
223
|
+
If the username is already in use, or anything else goes wrong, an error with code `validation_errors` will be returned.
|
67
224
|
|
68
|
-
|
69
|
-
|
225
|
+
Omit the `username` when authenticating (logging in).
|
226
|
+
The JSON response will be the `options_for_get` from webauthn.
|
227
|
+
|
228
|
+
#### POST /passkeys/register
|
229
|
+
|
230
|
+
After calling the `challenge` endpoint with a `username`, and handling its response, finish registering by calling this endpoint.
|
231
|
+
|
232
|
+
Supply the following JSON structure:
|
233
|
+
|
234
|
+
```JSON
|
235
|
+
# POST body
|
236
|
+
{
|
237
|
+
# NOTE: credential will likely come directly from the PassKeys class/library on the platform
|
238
|
+
"credential": {
|
239
|
+
"id": String,
|
240
|
+
"rawId": String,
|
241
|
+
"type": String,
|
242
|
+
"response": {
|
243
|
+
"attestationObject": String,
|
244
|
+
"clientDataJSON": String
|
245
|
+
}
|
246
|
+
},
|
247
|
+
# authenticatable is optional and is informas PasskeysRails how to build your "user" model
|
248
|
+
"authenticatable": { # optional
|
249
|
+
"class": "User", # whatever class to which you want this credential to apply (as described earlier)
|
250
|
+
"params": { } # Any params you want passed as a hash to the registering_with method on that class
|
251
|
+
}
|
252
|
+
}
|
253
|
+
```
|
254
|
+
|
255
|
+
On **success**, the response is an `AuthResponse`.
|
256
|
+
|
257
|
+
Possible **failure codes** (using the `ErrorResponse` structure) are:
|
258
|
+
|
259
|
+
- webauthn_error - something is wrong with the credential
|
260
|
+
- error - something else went wrong during credentail validation - see the `message` in the `ErrorResponse`
|
261
|
+
- passkey_error - unable to persiste the passkey
|
262
|
+
- invalid_authenticatable_class - the supplied authenticatable class can't be created/found (check spelling & capitalization)
|
263
|
+
- invalid_class_whitelist - the whitelist in the passkeys_rails.rb configuration is invalid - be sure it's nil or an array
|
264
|
+
- invalid_authenticatable_class - the supplied authenticatable class is not allowed - maybe it's not in the whitelist
|
265
|
+
- record_invalid - the object of the supplied authenticatable class cannot be saved due to validation errors
|
266
|
+
- agent_not_found - the agent referenced in the credential cannot be found in the database
|
267
|
+
|
268
|
+
#### POST /passkeys/authenticate
|
269
|
+
|
270
|
+
After calling the `challenge` endpoint without a `username`, and handling its response, finish authenticating by calling this endpoint.
|
271
|
+
|
272
|
+
Supply the following JSON structure:
|
273
|
+
|
274
|
+
```JSON
|
275
|
+
# POST body
|
276
|
+
{
|
277
|
+
# NOTE: all of this will likely come directly from the PassKeys class/library on the platform
|
278
|
+
"id": String, # Base64 encoded assertion.credentialID
|
279
|
+
"rawId": String, # Base64 encoded assertion.credentialID
|
280
|
+
"type": "public-key",
|
281
|
+
"response": {
|
282
|
+
"authenticatorData": String, # Base64 encoded assertion.rawAuthenticatorData
|
283
|
+
"clientDataJSON": String, # Base64 encoded assertion.rawClientDataJSON
|
284
|
+
"signature": String, # Base64 encoded signature
|
285
|
+
"userHandle":String # Base64 encoded assertion.userID
|
286
|
+
}
|
287
|
+
}
|
288
|
+
```
|
289
|
+
On **success**, the response is an `AuthResponse`.
|
290
|
+
|
291
|
+
Possible **failure codes** (using the `ErrorResponse` structure) are:
|
292
|
+
|
293
|
+
- webauthn_error - something is wrong with the credential
|
294
|
+
- passkey_not_found - the passkey referenced in the credential cannot be found in the database
|
295
|
+
|
296
|
+
#### POST /passkeys/refresh
|
297
|
+
|
298
|
+
The token will expire after some time (configurable in passkeys_rails.rb). Before that happens, refresh it using this API. Once it's expired, to get a new token, use the /authentication API.
|
299
|
+
|
300
|
+
Supply the following JSON structure:
|
301
|
+
|
302
|
+
```JSON
|
303
|
+
# POST body
|
304
|
+
{
|
305
|
+
token: String
|
306
|
+
}
|
307
|
+
```
|
308
|
+
|
309
|
+
On **success**, the response is an `AuthResponse` with a new, refreshed token.
|
310
|
+
|
311
|
+
Possible **failure codes** (using the `ErrorResponse` structure) are:
|
312
|
+
|
313
|
+
- invalid_token - the token data is invalid
|
314
|
+
- expired_token - the token is expired
|
315
|
+
- token_error - some other error ocurred when decoding the token
|
316
|
+
|
317
|
+
#### POST /passkeys/debug_login
|
318
|
+
|
319
|
+
As it may not be possible to acess Passkey functionality in mobile simulators, this endpoint may be called to login (authenticate) a username while bypassing the normal challenge/response sequence.
|
320
|
+
|
321
|
+
This endpoint only responds if DEBUG_LOGIN_REGEX is set in the server environment. It is very insecure to set this variable in a production environment as it bypasses all Passkey checks. It is only intended to be used during mobile application development.
|
322
|
+
|
323
|
+
To use this endpoint:
|
324
|
+
|
325
|
+
1. Manually create one or more PasskeysRails::Agent records in the database. A unique username is required for each.
|
326
|
+
|
327
|
+
1. Set DEBUG_LOGIN_REGEX to a regex that matches any username you want to use during development - for example `^test(-\d+)?$` will match `test`, `test-1`, `test-123`, etc.
|
328
|
+
|
329
|
+
1. In the mobile application, call this endpoint in stead of the /passkeys/challenge and /passkeys/authenticate. The response is identicial to that of /passkeys/authenticate.
|
330
|
+
|
331
|
+
1. Use the response as if it was from /passkeys/authenticate.
|
332
|
+
|
333
|
+
If you supply a username that doesn't match the DEBUG_LOGIN_REGEX, the endpoint will respond with an error.
|
334
|
+
|
335
|
+
```JSON
|
336
|
+
# POST body
|
337
|
+
{
|
338
|
+
"username": String
|
339
|
+
}
|
340
|
+
```
|
341
|
+
On **success**, the response is an `AuthResponse`.
|
342
|
+
|
343
|
+
Possible **failure codes** (using the `ErrorResponse` structure) are:
|
344
|
+
|
345
|
+
- not_allowed - Invalid username (the username doesn't match the regex)
|
346
|
+
- agent_not_found - No agent found with that username
|
347
|
+
|
348
|
+
## Reference/Example Mobile Applications
|
349
|
+
|
350
|
+
**TODO**: Point to the soon-to-be-created reference mobile applications for how to use **passkeys-rails** for passkey authentication.
|
70
351
|
|
71
352
|
## Contributing
|
72
|
-
|
353
|
+
|
354
|
+
### Contributing Guidelines
|
355
|
+
|
356
|
+
Thank you for considering contributing to PasskeysRails! We welcome your help to improve and enhance this project. Whether it's a bug fix, documentation update, or a new feature, your contributions are valuable to the community.
|
357
|
+
|
358
|
+
To ensure a smooth collaboration, please follow the guidelines below when submitting your contributions:
|
359
|
+
|
360
|
+
#### Code of Conduct
|
361
|
+
|
362
|
+
Please note that this project follows the [Code of Conduct](https://github.com/alliedcode/passkeys-rails/blob/main/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. If you encounter any behavior that violates the code, please report it to the project maintainers.
|
363
|
+
|
364
|
+
#### How to Contribute
|
365
|
+
|
366
|
+
1. Fork the repository on GitHub.
|
367
|
+
|
368
|
+
2. Create a new branch for your contribution. Use a descriptive name that reflects the purpose of your changes.
|
369
|
+
|
370
|
+
3. Make your changes and commit them with clear and concise messages. Remember to follow the project's coding style and guidelines.
|
371
|
+
|
372
|
+
4. Before submitting a pull request, ensure that your changes pass all existing tests and add relevant tests if applicable.
|
373
|
+
|
374
|
+
5. Update the documentation if your changes introduce new features, modify existing behavior, or require user instructions.
|
375
|
+
|
376
|
+
6. Squash your commits into a single logical commit if needed. Keep your commit history clean and focused.
|
377
|
+
|
378
|
+
7. Submit a pull request against the `main` branch of the original repository.
|
379
|
+
|
380
|
+
8. Add a comment at the top of the CHANGELOG.md describing the change.
|
381
|
+
|
382
|
+
#### Pull Request Guidelines
|
383
|
+
|
384
|
+
When submitting a pull request, please include the following details:
|
385
|
+
|
386
|
+
- A clear description of the changes you made and the problem it solves.
|
387
|
+
|
388
|
+
- Any relevant issue numbers that your pull request addresses or fixes.
|
389
|
+
|
390
|
+
- The steps to test your changes, so the project maintainers can verify them.
|
391
|
+
|
392
|
+
- Ensure that your pull request title and description are descriptive and informative.
|
393
|
+
|
394
|
+
#### Code Review Process
|
395
|
+
|
396
|
+
All pull requests will undergo a code review process by the project maintainers. We appreciate your patience during this review process. Constructive feedback may be provided, and further changes might be requested.
|
397
|
+
|
398
|
+
#### Contributor License Agreement
|
399
|
+
|
400
|
+
By submitting a pull request, you acknowledge that your contributions will be licensed under the project's [MIT License](https://github.com/alliedcode/passkeys-rails/blob/main/MIT-LICENSE).
|
401
|
+
|
402
|
+
#### Reporting Issues
|
403
|
+
|
404
|
+
If you encounter any bugs, problems, or have suggestions for improvement, please create an issue on the GitHub repository. Provide clear and detailed information about the issue to help us address it efficiently.
|
405
|
+
|
406
|
+
#### Thank You
|
407
|
+
|
408
|
+
Your contributions are valuable, and we sincerely appreciate your efforts to improve PasskeysRails. Together, we can build a better software ecosystem for the community. Thank you for your support and happy contributing!
|
409
|
+
|
73
410
|
|
74
411
|
## License
|
75
|
-
|
412
|
+
|
413
|
+
The gem is available as open source under the terms of the [MIT License](https://github.com/alliedcode/passkeys-rails/blob/main/MIT-LICENSE).
|
@@ -9,22 +9,17 @@ module PasskeysRails
|
|
9
9
|
end
|
10
10
|
|
11
11
|
def current_agent
|
12
|
-
@current_agent ||= (
|
13
|
-
passkey_authentication_result.success? &&
|
12
|
+
@current_agent ||= (passkey_authentication_result.success? &&
|
14
13
|
passkey_authentication_result.agent.registered? &&
|
15
14
|
passkey_authentication_result.agent) || nil
|
16
15
|
end
|
17
16
|
|
18
17
|
def authenticate_passkey!
|
19
|
-
|
20
|
-
|
21
|
-
raise PasskeysRails::Error.new(:authentication,
|
22
|
-
code: :unauthorized,
|
23
|
-
message: "You are not authorized to access this resource.")
|
18
|
+
@authenticate_passkey ||= PasskeysRails.authenticate!(request)
|
24
19
|
end
|
25
20
|
|
26
21
|
def passkey_authentication_result
|
27
|
-
@passkey_authentication_result ||= PasskeysRails
|
22
|
+
@passkey_authentication_result ||= PasskeysRails.authenticate(request)
|
28
23
|
end
|
29
24
|
end
|
30
25
|
end
|
@@ -11,7 +11,7 @@ module PasskeysRails
|
|
11
11
|
|
12
12
|
def register
|
13
13
|
result = PasskeysRails::FinishRegistration.call!(credential: attestation_credential_params.to_h,
|
14
|
-
|
14
|
+
authenticatable_info: authenticatable_info&.to_h,
|
15
15
|
username: session.dig(:passkeys_rails, :username),
|
16
16
|
challenge: session.dig(:passkeys_rails, :challenge))
|
17
17
|
|
@@ -30,6 +30,15 @@ module PasskeysRails
|
|
30
30
|
render json: { username: result.username, auth_token: result.auth_token }
|
31
31
|
end
|
32
32
|
|
33
|
+
# This action exists to allow easier mobile app debugging as it may not
|
34
|
+
# be possible to acess Passkey functionality in mobile simulators.
|
35
|
+
# It is only routable if DEBUG_LOGIN_REGEX is set in the server environment.
|
36
|
+
# CAUTION: It is very insecure to set DEBUG_LOGIN_REGEX in a production environment.
|
37
|
+
def debug_login
|
38
|
+
result = PasskeysRails::DebugLogin.call!(username: debug_login_params[:username])
|
39
|
+
render json: { username: result.username, auth_token: result.auth_token }
|
40
|
+
end
|
41
|
+
|
33
42
|
protected
|
34
43
|
|
35
44
|
def challenge_params
|
@@ -43,8 +52,8 @@ module PasskeysRails
|
|
43
52
|
credential.permit(:id, :rawId, :type, { response: %i[attestationObject clientDataJSON] })
|
44
53
|
end
|
45
54
|
|
46
|
-
def
|
47
|
-
params[:
|
55
|
+
def authenticatable_info
|
56
|
+
params.require[:authenticatable].permit(:class, :params) if params[:authenticatable].present?
|
48
57
|
end
|
49
58
|
|
50
59
|
def authentication_params
|
@@ -57,5 +66,10 @@ module PasskeysRails
|
|
57
66
|
params.require(:auth_token)
|
58
67
|
params.permit(:auth_token)
|
59
68
|
end
|
69
|
+
|
70
|
+
def debug_login_params
|
71
|
+
params.require(:username)
|
72
|
+
params.permit(:username)
|
73
|
+
end
|
60
74
|
end
|
61
75
|
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# This functionality exists to allow easier mobile app debugging as it may not
|
2
|
+
# be possible to acess Passkey functionality in mobile simulators.
|
3
|
+
# It is only operational if DEBUG_LOGIN_REGEX is set in the server environment.
|
4
|
+
# CAUTION: It is very insecure to set DEBUG_LOGIN_REGEX in a production environment.
|
5
|
+
module PasskeysRails
|
6
|
+
class DebugLogin
|
7
|
+
include Interactor
|
8
|
+
|
9
|
+
delegate :username, to: :context
|
10
|
+
|
11
|
+
def call
|
12
|
+
ensure_debug_mode
|
13
|
+
ensure_regex_match
|
14
|
+
|
15
|
+
context.username = agent.username
|
16
|
+
context.auth_token = GenerateAuthToken.call!(agent:).auth_token
|
17
|
+
rescue Interactor::Failure => e
|
18
|
+
context.fail! code: e.context.code, message: e.context.message
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def ensure_debug_mode
|
24
|
+
context.fail!(code: :not_allowed, message: 'Action not allowed') if username_regex.blank?
|
25
|
+
end
|
26
|
+
|
27
|
+
def ensure_regex_match
|
28
|
+
context.fail!(code: :not_allowed, message: 'Invalid username') unless username&.match?(username_regex)
|
29
|
+
end
|
30
|
+
|
31
|
+
def username_regex
|
32
|
+
PasskeysRails.debug_login_regex
|
33
|
+
end
|
34
|
+
|
35
|
+
def agent
|
36
|
+
@agent ||= begin
|
37
|
+
agent = Agent.find_by(username:)
|
38
|
+
context.fail!(code: :agent_not_found, message: "No agent found with that username") if agent.blank?
|
39
|
+
agent
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -3,7 +3,7 @@ module PasskeysRails
|
|
3
3
|
class FinishRegistration
|
4
4
|
include Interactor
|
5
5
|
|
6
|
-
delegate :credential, :username, :challenge, :
|
6
|
+
delegate :credential, :username, :challenge, :authenticatable_info, to: :context
|
7
7
|
|
8
8
|
def call
|
9
9
|
verify_credential!
|
@@ -44,6 +44,14 @@ module PasskeysRails
|
|
44
44
|
end
|
45
45
|
end
|
46
46
|
|
47
|
+
def authenticatable_class
|
48
|
+
authenticatable_info && authenticatable_info[:class]
|
49
|
+
end
|
50
|
+
|
51
|
+
def authenticatable_params
|
52
|
+
authenticatable_info && authenticatable_info[:params]
|
53
|
+
end
|
54
|
+
|
47
55
|
def aux_class_name
|
48
56
|
@aux_class_name ||= authenticatable_class || PasskeysRails.default_class
|
49
57
|
end
|
@@ -52,25 +60,27 @@ module PasskeysRails
|
|
52
60
|
whitelist = PasskeysRails.class_whitelist
|
53
61
|
|
54
62
|
@aux_class ||= begin
|
55
|
-
|
56
|
-
when Array
|
63
|
+
if whitelist.is_a?(Array)
|
57
64
|
unless whitelist.include?(aux_class_name)
|
58
65
|
context.fail!(code: :invalid_authenticatable_class, message: "authenticatable_class (#{aux_class_name}) is not in the whitelist")
|
59
66
|
end
|
60
|
-
|
67
|
+
elsif whitelist.present?
|
61
68
|
context.fail!(code: :invalid_class_whitelist,
|
62
69
|
message: "class_whitelist is invalid. It should be nil or an array of zero or more class names.")
|
63
70
|
end
|
64
71
|
|
65
|
-
|
66
|
-
|
67
|
-
|
72
|
+
begin
|
73
|
+
aux_class_name.constantize
|
74
|
+
rescue StandardError
|
75
|
+
context.fail!(code: :invalid_authenticatable_class, message: "authenticatable_class (#{aux_class_name}) is not defined")
|
76
|
+
end
|
68
77
|
end
|
69
78
|
end
|
70
79
|
|
71
80
|
def create_authenticatable!
|
72
81
|
authenticatable = aux_class.create! do |obj|
|
73
|
-
obj.
|
82
|
+
obj.agent = agent if obj.respond_to?(:agent=)
|
83
|
+
obj.registering_with(authenticatable_params) if obj.respond_to?(:registering_with)
|
74
84
|
end
|
75
85
|
|
76
86
|
agent.update!(authenticatable:)
|
@@ -5,11 +5,11 @@ module PasskeysRails
|
|
5
5
|
extend ActiveSupport::Concern
|
6
6
|
|
7
7
|
included do
|
8
|
-
has_one :agent, as: :authenticatable
|
8
|
+
has_one :agent, as: :authenticatable, class_name: "PasskeysRails::Agent"
|
9
9
|
|
10
10
|
delegate :registered?, to: :agent, allow_nil: true
|
11
11
|
|
12
|
-
def registering_with(
|
12
|
+
def registering_with(_params)
|
13
13
|
# initialize required attributes
|
14
14
|
end
|
15
15
|
end
|
data/config/routes.rb
CHANGED
@@ -3,4 +3,11 @@ PasskeysRails::Engine.routes.draw do
|
|
3
3
|
post 'passkeys/register'
|
4
4
|
post 'passkeys/authenticate'
|
5
5
|
post 'passkeys/refresh'
|
6
|
+
|
7
|
+
# This route exists to allow easier mobile app debugging as it may not
|
8
|
+
# be possible to acess Passkey functionality in mobile simulators.
|
9
|
+
# CAUTION: It is very insecure to set DEBUG_LOGIN_REGEX in a production environment.
|
10
|
+
constraints(->(_request) { PasskeysRails.debug_login_regex.present? }) do
|
11
|
+
post 'passkeys/debug_login'
|
12
|
+
end
|
6
13
|
end
|
@@ -5,14 +5,22 @@ module PasskeysRails
|
|
5
5
|
class InstallGenerator < ::Rails::Generators::Base
|
6
6
|
source_root File.expand_path("templates", __dir__)
|
7
7
|
|
8
|
+
desc "Adds passkeys config file to your application."
|
8
9
|
def copy_config
|
9
10
|
template 'passkeys_rails_config.rb', "config/initializers/passkeys_rails.rb"
|
10
11
|
end
|
11
12
|
|
13
|
+
desc "Adds passkeys routes to your application."
|
12
14
|
def add_routes
|
13
|
-
route 'mount PasskeysRails::Engine => "/
|
15
|
+
route 'mount PasskeysRails::Engine => "/passkeys"'
|
14
16
|
end
|
15
17
|
|
18
|
+
desc "Copies migrations to your application."
|
19
|
+
def copy_migrations
|
20
|
+
rake("passkeys_rails:install:migrations")
|
21
|
+
end
|
22
|
+
|
23
|
+
desc "Displays readme during installation."
|
16
24
|
def show_readme
|
17
25
|
readme "README" if behavior == :invoke
|
18
26
|
end
|
data/lib/passkeys-rails.rb
CHANGED
@@ -4,6 +4,10 @@ require 'passkeys_rails/version'
|
|
4
4
|
require_relative "generators/passkeys_rails/install_generator"
|
5
5
|
|
6
6
|
module PasskeysRails
|
7
|
+
module Test
|
8
|
+
autoload :IntegrationHelpers, 'passkeys_rails/test/integration_helpers'
|
9
|
+
end
|
10
|
+
|
7
11
|
# Secret used to encode the auth token.
|
8
12
|
# Rails.application.secret_key_base is used if none is defined here.
|
9
13
|
# Changing this value will invalidate all tokens that have been fetched
|
@@ -41,6 +45,34 @@ module PasskeysRails
|
|
41
45
|
# for example: %w[User AdminUser]
|
42
46
|
mattr_accessor :class_whitelist, default: nil
|
43
47
|
|
48
|
+
# This is only used by the debug_login endpoint.
|
49
|
+
# CAUTION: It is very insecure to set DEBUG_LOGIN_REGEX in a production environment.
|
50
|
+
def self.debug_login_regex
|
51
|
+
ENV['DEBUG_LOGIN_REGEX'].present? ? Regexp.new(ENV['DEBUG_LOGIN_REGEX']) : nil
|
52
|
+
end
|
53
|
+
|
54
|
+
# Returns an Interactor::Context that indicates if the request is authentic.
|
55
|
+
#
|
56
|
+
# .success? is true if authentic
|
57
|
+
# .agent is the Passkey::Agent on success
|
58
|
+
#
|
59
|
+
# .failure? is true if failed (just the opposite of .success?)
|
60
|
+
# .code is the error code on failure
|
61
|
+
# .message is the human readable error message on failure
|
62
|
+
def self.authenticate(request)
|
63
|
+
PasskeysRails::ValidateAuthToken.call(auth_token: request.headers['X-Auth'])
|
64
|
+
end
|
65
|
+
|
66
|
+
# Raises a PasskeysRails::Error exception if the request is not authentic.
|
67
|
+
def self.authenticate!(request)
|
68
|
+
auth = authenticate(request)
|
69
|
+
return if auth.success?
|
70
|
+
|
71
|
+
raise PasskeysRails::Error.new(:authentication,
|
72
|
+
code: auth.code,
|
73
|
+
message: auth.message)
|
74
|
+
end
|
75
|
+
|
44
76
|
class << self
|
45
77
|
def config
|
46
78
|
yield self
|
@@ -0,0 +1,46 @@
|
|
1
|
+
module PasskeysRails
|
2
|
+
# PasskeysRails::Test::IntegrationHelpers is a helper module for facilitating
|
3
|
+
# authentication on Rails integration tests to bypass the required steps for
|
4
|
+
# signin in or signin out a record.
|
5
|
+
#
|
6
|
+
# Examples
|
7
|
+
#
|
8
|
+
# class PostsTest < ActionDispatch::IntegrationTest
|
9
|
+
# include PasskeysRails::Test::IntegrationHelpers
|
10
|
+
#
|
11
|
+
# test 'authenticated users can see posts' do
|
12
|
+
# get '/posts', headers: logged_in_headers('username-1')
|
13
|
+
# assert_response :success
|
14
|
+
# end
|
15
|
+
# end
|
16
|
+
module Test
|
17
|
+
module IntegrationHelpers
|
18
|
+
def self.included(base)
|
19
|
+
base.class_eval do
|
20
|
+
setup :setup_integration_for_passkeys_rails
|
21
|
+
teardown :teardown_integration_for_passkeys_rails
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def logged_in_headers(username, authenticatable = nil, headers: {})
|
26
|
+
@agent = Agent.create(username:, registered_at: Time.current, authenticatable:)
|
27
|
+
result = PasskeysRails::GenerateAuthToken.call(agent:)
|
28
|
+
raise result.message if result.failure?
|
29
|
+
|
30
|
+
headers.merge("X-Auth" => result.auth_token)
|
31
|
+
end
|
32
|
+
|
33
|
+
protected
|
34
|
+
|
35
|
+
attr_reader :agent
|
36
|
+
|
37
|
+
def setup_integration_for_passkeys_rails
|
38
|
+
# Nothing to do here
|
39
|
+
end
|
40
|
+
|
41
|
+
def teardown_integration_for_passkeys_rails
|
42
|
+
@agent&.destroy
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: passkeys-rails
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Troy Anderson
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-07-
|
11
|
+
date: 2023-07-28 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -357,6 +357,7 @@ files:
|
|
357
357
|
- app/interactors/passkeys_rails/begin_authentication.rb
|
358
358
|
- app/interactors/passkeys_rails/begin_challenge.rb
|
359
359
|
- app/interactors/passkeys_rails/begin_registration.rb
|
360
|
+
- app/interactors/passkeys_rails/debug_login.rb
|
360
361
|
- app/interactors/passkeys_rails/finish_authentication.rb
|
361
362
|
- app/interactors/passkeys_rails/finish_registration.rb
|
362
363
|
- app/interactors/passkeys_rails/generate_auth_token.rb
|
@@ -378,6 +379,7 @@ files:
|
|
378
379
|
- lib/passkeys-rails.rb
|
379
380
|
- lib/passkeys_rails/engine.rb
|
380
381
|
- lib/passkeys_rails/railtie.rb
|
382
|
+
- lib/passkeys_rails/test/integration_helpers.rb
|
381
383
|
- lib/passkeys_rails/version.rb
|
382
384
|
- lib/tasks/passkeys_rails_tasks.rake
|
383
385
|
homepage: https://github.com/alliedcode/passkeys-rails
|