Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@

- Add `Devise::Webauthn.challenge_store` to configure where WebAuthn challenges are kept between the options request and the verification request. It takes a symbol such as `:session`, or a store class. The default, `:session`, keeps them in the session as before. [#150](https://github.com/cedarcode/devise-webauthn/pull/150) [@RenzoMinelli]
- Add the `:cache` challenge store, which keeps challenges in `Rails.cache` instead of the session. [#152](https://github.com/cedarcode/devise-webauthn/pull/152) [@RenzoMinelli]
- Add the `:signed` challenge store, which signs each challenge instead of storing it. API clients can use passkeys without a session or a shared cache. [#157](https://github.com/cedarcode/devise-webauthn/pull/157) [@RenzoMinelli]
- Accept `public_key_credential` as a JSON object as well as a JSON string. [#153](https://github.com/cedarcode/devise-webauthn/pull/153) [@RenzoMinelli]
- Respond with JSON from the passkey and security key controllers when the request is not navigational, through Devise's responder. [#153](https://github.com/cedarcode/devise-webauthn/pull/153) [@RenzoMinelli]
- Return a `two_factor_token` to API clients when a password sign-in needs a second factor, and accept it instead of the session to finish two-factor sign-in. [#154](https://github.com/cedarcode/devise-webauthn/pull/154) [@RenzoMinelli]

### Changed

- Passkey sign-in no longer stores the user in the session when `skip_session_storage` includes `:params_auth`, the same as password sign-in. [#153](https://github.com/cedarcode/devise-webauthn/pull/153) [@RenzoMinelli]
- Two-factor sign-in with a security key follows the same `skip_session_storage` setting. [#154](https://github.com/cedarcode/devise-webauthn/pull/154) [@RenzoMinelli]
- The WebAuthn options controllers only call `skip_forgery_protection` when it is defined, so they load under an `ActionController::API` parent. [#153](https://github.com/cedarcode/devise-webauthn/pull/153) [@RenzoMinelli]

## [v0.5.0](https://github.com/cedarcode/devise-webauthn/compare/v0.4.0...v0.5.0/) - 2026-07-13

Expand Down
78 changes: 70 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,18 +133,80 @@ The two factor authentication flow with WebAuthn works as follows:

## Challenge store

By default, WebAuthn challenges are kept in the session between the options request and the sign-in or registration request. To keep them in a cache instead:
By default, WebAuthn challenges are kept in the session between the options request and the sign-in or registration request. To keep them outside the session, choose one store:

- `:cache` keeps challenges in a cache:
```ruby
# config/initializers/devise_webauthn.rb
Devise::Webauthn.challenge_store = :cache
# Optional. Defaults to Rails.cache and 5 minutes.
Devise::Webauthn::ChallengeStores::Cache.cache = Rails.cache
Devise::Webauthn::ChallengeStores::Cache.expires_in = 5.minutes
```
Each challenge can be used once. The two requests can reach different servers or processes, so use a cache shared by all your servers (Redis, Memcached or Solid Cache). `ActiveSupport::Cache::MemoryStore` and `FileStore` are local to one process or host.
- `:signed` stores nothing. The server signs each challenge with `secret_key_base`, and checks the signature when the credential comes back:
```ruby
# config/initializers/devise_webauthn.rb
Devise::Webauthn.challenge_store = :signed
# Optional. Defaults to 5 minutes.
Devise::Webauthn::ChallengeStores::Signed.expires_in = 5.minutes
```
A signed challenge can be used more than once until it expires, like a challenge in a cookie session. Keep `expires_in` short.

## API clients and native apps

Mobile apps and other API clients can use passkeys without a cookie session, for example with [devise-jwt](https://github.com/waiting-for-dev/devise-jwt).

### Server setup

1. Use the `:cache` or `:signed` [challenge store](#challenge-store).
2. Stop Devise from writing the user to the session. Passkey sign-in follows the `:params_auth` setting:
```ruby
# config/initializers/devise.rb
config.skip_session_storage = [:http_auth, :params_auth]
```
3. Let Devise controllers respond with JSON, and send `Accept: application/json` from the client:
```ruby
class ApplicationController < ActionController::API
respond_to :json
end
```
With `:registerable`, also override `sign_up` in your registrations controller to call `sign_in(resource_name, resource, store: false)`. Devise's default writes to the session.
4. Tell WebAuthn which relying party and origins to accept. Native apps do not have a browser origin:
```ruby
WebAuthn.configure do |config|
config.rp_id = "example.com"
config.allowed_origins = [
"https://example.com", # web and iOS
"android:apk-key-hash:<base64url SHA-256 of your signing certificate>"
]
end
```
Your domain must also serve `/.well-known/apple-app-site-association` (with `webcredentials`) and `/.well-known/assetlinks.json` (with `delegate_permission/common.get_login_creds`).

### Endpoints

Send `public_key_credential` as the JSON object your WebAuthn library returns. For a resource named `user`:

| Step | Request | Response |
|---|---|---|
| Sign-in options | `POST /users/passkey_authentication_options` | WebAuthn request options |
| Sign in | `POST /users/sign_in` with `public_key_credential` | `201`, and a JWT when you use devise-jwt |
| Registration options | `POST /users/passkey_registration_options` (authenticated) | WebAuthn creation options |
| Add passkey | `POST /users/passkeys` with `name` and `public_key_credential` (authenticated) | `201` with the passkey, or `422` with `errors` |
| Delete passkey | `DELETE /users/passkeys/:id` (authenticated) | `204` |

### Two-factor authentication

When a user with security keys signs in with a password, the response is `401` with a `two_factor_token`. Send that token to `POST /users/security_key_authentication_options` and to `POST /users/two_factor_authentication` together with `public_key_credential`. The token expires after 5 minutes (`Devise::Webauthn::TwoFactorToken.expires_in`).

devise-jwt only issues a token on `POST /users/sign_in` by default. Add the 2FA path:
```ruby
# config/initializers/devise_webauthn.rb
Devise::Webauthn.challenge_store = :cache
# Optional. Defaults to Rails.cache and 5 minutes.
Devise::Webauthn::ChallengeStores::Cache.cache = Rails.cache
Devise::Webauthn::ChallengeStores::Cache.expires_in = 5.minutes
config.jwt do |jwt|
jwt.dispatch_requests = [["POST", %r{^/users/two_factor_authentication$}]]
end
```

Each challenge can be used once. The two requests can reach different servers or processes, so use a cache shared by all your servers (Redis, Memcached or Solid Cache). `ActiveSupport::Cache::MemoryStore` and `FileStore` are local to one process or host.

## Customization

### Customizing Views
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,15 @@ module Devise
class PasskeyAuthenticationOptionsController < DeviseController
include Devise::Webauthn::ChallengeStoreAccess

skip_forgery_protection
skip_forgery_protection if respond_to?(:skip_forgery_protection)

def create
passkey_options =
WebAuthn::Credential.options_for_get(
user_verification: "required"
)

challenge_store.write(:passkey_authentication, passkey_options.challenge)

render json: passkey_options
render json: options_with_stored_challenge(:passkey_authentication, passkey_options)
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module Devise
class PasskeyRegistrationOptionsController < DeviseController
include Devise::Webauthn::ChallengeStoreAccess

skip_forgery_protection
skip_forgery_protection if respond_to?(:skip_forgery_protection)

before_action :authenticate_scope!
before_action :ensure_webauthn_id!
Expand All @@ -23,9 +23,7 @@ def create
}
)

challenge_store.write(:registration, passkey_options.challenge)

render json: passkey_options
render json: options_with_stored_challenge(:registration, passkey_options)
end

private
Expand Down
28 changes: 18 additions & 10 deletions app/controllers/devise/passkeys_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,34 @@
module Devise
class PasskeysController < DeviseController
include Devise::Webauthn::ChallengeStoreAccess
include Devise::Webauthn::PublicKeyCredentialParam

before_action :authenticate_scope!

def new; end

def create
passkey_from_params = WebAuthn::Credential.from_create(JSON.parse(params[:public_key_credential]))
passkey = verify_and_save_passkey(public_key_credential_param)

if verify_and_save_passkey(passkey_from_params)
if passkey.persisted?
set_flash_message! :notice, :passkey_created
else
set_flash_message! :alert, :passkey_verification_failed, scope: :"devise.failure"
end
redirect_to after_update_path
rescue WebAuthn::Error
set_flash_message! :alert, :passkey_verification_failed, scope: :"devise.failure"
redirect_to after_update_path
respond_with_navigational(passkey, location: after_update_path) { redirect_to after_update_path }
ensure
challenge_store.consume(:registration)
end

def destroy
if resource.passkeys.destroy(params[:id])
passkey = resource.passkeys.find(params[:id])

if passkey.destroy
set_flash_message! :notice, :passkey_deleted
else
set_flash_message! :alert, :passkey_deletion_failed, scope: :"devise.failure"
end

redirect_to after_update_path
respond_with_navigational(passkey, location: after_update_path) { redirect_to after_update_path }
end

private
Expand All @@ -41,7 +40,8 @@ def authenticate_scope!
self.resource = send(:"current_#{resource_name}")
end

def verify_and_save_passkey(passkey_from_params)
def verify_and_save_passkey(public_key_credential)
passkey_from_params = WebAuthn::Credential.from_create(public_key_credential)
passkey_from_params.verify(
challenge_store.consume(:registration),
user_verification: true
Expand All @@ -53,6 +53,14 @@ def verify_and_save_passkey(passkey_from_params)
public_key: passkey_from_params.public_key,
sign_count: passkey_from_params.sign_count
)
rescue WebAuthn::Error
unverified_passkey
end

def unverified_passkey
passkey = resource.passkeys.new
passkey.errors.add(:base, find_message(:passkey_verification_failed, scope: :"devise.failure"))
passkey
end

# The default url to be used after creating a passkey. You can overwrite
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,45 @@
module Devise
class SecondFactorWebauthnCredentialsController < DeviseController
include Devise::Webauthn::ChallengeStoreAccess
include Devise::Webauthn::PublicKeyCredentialParam

before_action :authenticate_scope!

def new; end

def create
security_key_from_params = WebAuthn::Credential.from_create(JSON.parse(params[:public_key_credential]))
security_key = verify_and_save_security_key(public_key_credential_param)

if verify_and_save_security_key(security_key_from_params)
if security_key.persisted?
set_flash_message! :notice, :security_key_created
else
set_flash_message! :alert, :webauthn_credential_verification_failed, scope: :"devise.failure"
end
redirect_to after_create_path
rescue WebAuthn::Error
set_flash_message! :alert, :webauthn_credential_verification_failed, scope: :"devise.failure"
redirect_to after_create_path
respond_with_navigational(security_key, location: after_create_path) { redirect_to after_create_path }
ensure
challenge_store.consume(:registration)
end

def update
if resource.second_factor_webauthn_credentials.find(params[:id]).update(authentication_factor: 0)
security_key = resource.second_factor_webauthn_credentials.find(params[:id])

if security_key.update(authentication_factor: 0)
set_flash_message! :notice, :security_key_promoted
else
set_flash_message! :alert, :security_key_promotion_failed, scope: :"devise.failure"
end

redirect_to after_update_path
respond_with_navigational(security_key, location: after_update_path) { redirect_to after_update_path }
end

def destroy
if resource.second_factor_webauthn_credentials.destroy(params[:id])
security_key = resource.second_factor_webauthn_credentials.find(params[:id])

if security_key.destroy
set_flash_message! :notice, :security_key_deleted
else
set_flash_message! :alert, :security_key_deletion_failed, scope: :"devise.failure"
end

redirect_to after_destroy_path
respond_with_navigational(security_key, location: after_destroy_path) { redirect_to after_destroy_path }
end

private
Expand All @@ -51,7 +51,8 @@ def authenticate_scope!
self.resource = send(:"current_#{resource_name}")
end

def verify_and_save_security_key(security_key_from_params)
def verify_and_save_security_key(public_key_credential)
security_key_from_params = WebAuthn::Credential.from_create(public_key_credential)
security_key_from_params.verify(
challenge_store.consume(:registration)
)
Expand All @@ -62,6 +63,14 @@ def verify_and_save_security_key(security_key_from_params)
public_key: security_key_from_params.public_key,
sign_count: security_key_from_params.sign_count
)
rescue WebAuthn::Error
unverified_security_key
end

def unverified_security_key
security_key = resource.second_factor_webauthn_credentials.new
security_key.errors.add(:base, find_message(:webauthn_credential_verification_failed, scope: :"devise.failure"))
security_key
end

# The default url to be used after creating a second factor key. You can overwrite
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
module Devise
class SecurityKeyAuthenticationOptionsController < DeviseController
include Devise::Webauthn::ChallengeStoreAccess
include Devise::Webauthn::PendingTwoFactorSignIn

skip_forgery_protection
skip_forgery_protection if respond_to?(:skip_forgery_protection)

before_action :set_resource

Expand All @@ -15,15 +16,13 @@ def create
user_verification: "discouraged"
)

challenge_store.write(:two_factor_authentication, security_key_authentication_options.challenge)

render json: security_key_authentication_options
render json: options_with_stored_challenge(:two_factor_authentication, security_key_authentication_options)
end

private

def set_resource
@resource = resource_class.find(session[:current_authentication_resource_id])
@resource = resource_class.find(pending_two_factor_sign_in(resource_name)&.fetch("id"))
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module Devise
class SecurityKeyRegistrationOptionsController < DeviseController
include Devise::Webauthn::ChallengeStoreAccess

skip_forgery_protection
skip_forgery_protection if respond_to?(:skip_forgery_protection)

before_action :authenticate_scope!
before_action :ensure_webauthn_id!
Expand All @@ -23,9 +23,7 @@ def create
}
)

challenge_store.write(:registration, create_security_key_options.challenge)

render json: create_security_key_options
render json: options_with_stored_challenge(:registration, create_security_key_options)
end

private
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

module Devise
class TwoFactorAuthenticationsController < DeviseController
include Devise::Webauthn::PendingTwoFactorSignIn

prepend_before_action :set_resource, only: :new
prepend_before_action :ensure_sign_in_initiated
prepend_before_action :require_no_authentication
Expand All @@ -25,14 +27,16 @@ def auth_options
private

def ensure_sign_in_initiated
return if session[:current_authentication_resource_id].present?
return if pending_two_factor_sign_in(resource_name).present?

throw :warden, scope: resource_name, message: :sign_in_not_initiated unless is_navigational_format?

set_flash_message! :alert, :sign_in_not_initiated, scope: :"devise.failure"
redirect_to new_session_path(resource_name)
end

def set_resource
@resource = resource_class.find(session[:current_authentication_resource_id])
@resource = resource_class.find(pending_two_factor_sign_in(resource_name)["id"])
end
end
end
Loading
Loading