passkey_auth 0.1.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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +30 -0
  3. data/CODE_OF_CONDUCT.md +132 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +334 -0
  6. data/Rakefile +12 -0
  7. data/app/controllers/passkey_auth/application_controller.rb +13 -0
  8. data/app/controllers/passkey_auth/magic_links_controller.rb +120 -0
  9. data/app/controllers/passkey_auth/webauthn/authentications/challenges_controller.rb +40 -0
  10. data/app/controllers/passkey_auth/webauthn/authentications_controller.rb +62 -0
  11. data/app/controllers/passkey_auth/webauthn/credentials/challenges_controller.rb +53 -0
  12. data/app/controllers/passkey_auth/webauthn/credentials_controller.rb +58 -0
  13. data/app/javascript/passkey_auth/controllers/index.js +4 -0
  14. data/app/javascript/passkey_auth/controllers/passkey_authentication_controller.js +156 -0
  15. data/app/javascript/passkey_auth/controllers/webauthn_auth_controller.js +40 -0
  16. data/app/javascript/passkey_auth/controllers/webauthn_register_controller.js +70 -0
  17. data/app/javascript/passkey_auth/index.js +3 -0
  18. data/app/javascript/passkey_auth/lib/passkey.js +103 -0
  19. data/app/mailers/passkey_auth/application_mailer.rb +8 -0
  20. data/app/mailers/passkey_auth/magic_link_mailer.rb +17 -0
  21. data/app/models/passkey_auth/magic_link/short_code.rb +29 -0
  22. data/app/models/passkey_auth/magic_link.rb +92 -0
  23. data/app/models/passkey_auth/webauthn_credential.rb +14 -0
  24. data/app/views/passkey_auth/magic_link_mailer/login_link.html.erb +45 -0
  25. data/app/views/passkey_auth/magic_links/new.html.erb +19 -0
  26. data/app/views/passkey_auth/magic_links/verify_code.html.erb +21 -0
  27. data/config/locales/en.yml +53 -0
  28. data/config/routes.rb +28 -0
  29. data/lib/generators/passkey_auth/install/install_generator.rb +98 -0
  30. data/lib/generators/passkey_auth/install/templates/add_passwordless_to_users.rb.erb +8 -0
  31. data/lib/generators/passkey_auth/install/templates/create_passkey_auth_magic_links.rb.erb +21 -0
  32. data/lib/generators/passkey_auth/install/templates/create_passkey_auth_webauthn_credentials.rb.erb +16 -0
  33. data/lib/generators/passkey_auth/install/templates/initializer.rb +26 -0
  34. data/lib/passkey_auth/concerns/passwordless.rb +102 -0
  35. data/lib/passkey_auth/engine.rb +32 -0
  36. data/lib/passkey_auth/version.rb +5 -0
  37. data/lib/passkey_auth.rb +34 -0
  38. data/sig/passkey_auth.rbs +4 -0
  39. metadata +127 -0
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PasskeyAuth
4
+ class MagicLink < ApplicationRecord
5
+ self.table_name = "passkey_auth_magic_links"
6
+
7
+ belongs_to :user, class_name: PasskeyAuth.user_model_name, foreign_key: :user_id
8
+
9
+ PURPOSES = %w[login recovery email_confirmation].freeze
10
+
11
+ has_secure_token :token, length: 32
12
+
13
+ validates :expires_at, presence: true
14
+ validates :purpose, inclusion: { in: PURPOSES }
15
+
16
+ scope :active, -> { where(used_at: nil).where("expires_at > ?", Time.current) }
17
+ scope :expired, -> { where("expires_at <= ?", Time.current) }
18
+ scope :used, -> { where.not(used_at: nil) }
19
+
20
+ before_validation :set_defaults, on: :create
21
+
22
+ def expired?
23
+ expires_at <= Time.current
24
+ end
25
+
26
+ def used?
27
+ used_at.present?
28
+ end
29
+
30
+ def valid_for_use?
31
+ !expired? && !used?
32
+ end
33
+
34
+ def use!(ip: nil, user_agent: nil)
35
+ with_lock do
36
+ return false unless valid_for_use?
37
+
38
+ update!(
39
+ used_at: Time.current,
40
+ ip_address: ip,
41
+ user_agent: user_agent
42
+ )
43
+ end
44
+ end
45
+
46
+ def self.consume_by_token(token, ip:, user_agent:)
47
+ link = find_by(token: token)
48
+
49
+ return unless link
50
+
51
+ link.use!(ip: ip, user_agent: user_agent) ? link : nil
52
+ end
53
+
54
+ def self.consume_by_email_and_code(email, code, ip:, user_agent:)
55
+ cleaned_code = code&.upcase&.strip
56
+ cleaned_email = email&.downcase&.strip
57
+
58
+ # Use configured email attribute
59
+ email_attr = PasskeyAuth.user_email_attribute
60
+ link = includes(:user).find_by(short_code: cleaned_code, user: { email_attr => cleaned_email })
61
+
62
+ return unless link
63
+
64
+ link.use!(ip: ip, user_agent: user_agent) ? link : nil
65
+ end
66
+
67
+ def self.generate_short_code
68
+ ShortCode.generate
69
+ end
70
+
71
+ def self.cleanup_expired
72
+ expired.destroy_all
73
+ end
74
+
75
+ def invalid_reason
76
+ if expired?
77
+ :expired
78
+ elsif used?
79
+ :used
80
+ else
81
+ :generic
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def set_defaults
88
+ self.short_code ||= self.class.generate_short_code
89
+ self.expires_at ||= PasskeyAuth.magic_link_expiration.from_now
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PasskeyAuth
4
+ class WebauthnCredential < ApplicationRecord
5
+ self.table_name = "passkey_auth_webauthn_credentials"
6
+
7
+ belongs_to :user, class_name: PasskeyAuth.user_model_name, foreign_key: :user_id
8
+
9
+ validates :external_id, presence: true, uniqueness: true
10
+ validates :public_key, presence: true
11
+ validates :nickname, presence: true, uniqueness: { scope: :user_id }
12
+ validates :sign_count, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
13
+ end
14
+ end
@@ -0,0 +1,45 @@
1
+ <div style="padding: 40px;">
2
+ <h1 style="color: #1a1a1a; font-size: 24px; font-weight: 600; margin: 0 0 24px 0;">
3
+ <%= I18n.t("passkey_auth.magic_link_mailer.login_link.title") %>
4
+ </h1>
5
+
6
+ <p style="color: #4a4a4a; font-size: 16px; line-height: 24px; margin: 0 0 16px 0;">
7
+ <%= I18n.t("passkey_auth.magic_link_mailer.login_link.greeting", name: @user.try(:first_name) || PasskeyAuth.user_email(@user).split("@").first) %>
8
+ </p>
9
+
10
+ <p style="color: #4a4a4a; font-size: 16px; line-height: 24px; margin: 0 0 32px 0;">
11
+ <%= I18n.t("passkey_auth.magic_link_mailer.login_link.body") %>
12
+ </p>
13
+
14
+ <div style="text-align: center; margin: 32px 0;">
15
+ <%= link_to I18n.t("passkey_auth.magic_link_mailer.login_link.button"), @url,
16
+ style: "display: inline-block; padding: 14px 32px; background-color: #5469d4; color: white; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 16px;" %>
17
+ </div>
18
+
19
+ <p style="color: #4a4a4a; font-size: 16px; line-height: 24px; margin: 32px 0 16px 0;">
20
+ <%= I18n.t("passkey_auth.magic_link_mailer.login_link.code_prompt") %>
21
+ </p>
22
+
23
+ <p style="font-size: 28px; font-weight: 600; text-align: center; letter-spacing: 2px; color: #1a1a1a; margin: 16px 0 32px 0;" data-otp="<%= @short_code.tr('-', '') %>">
24
+ <%= @short_code %>
25
+ </p>
26
+
27
+ <p style="color: #6b6b6b; font-size: 14px; line-height: 20px; margin: 0 0 16px 0;">
28
+ <%= I18n.t("passkey_auth.magic_link_mailer.login_link.copy_link") %><br>
29
+ <%= @url %>
30
+ </p>
31
+
32
+ <p style="color: #6b6b6b; font-size: 14px; line-height: 20px; margin: 0 0 16px 0;">
33
+ <strong><%= I18n.t("passkey_auth.magic_link_mailer.login_link.expires", minutes: @expires_in) %></strong>
34
+ </p>
35
+
36
+ <p style="color: #6b6b6b; font-size: 14px; line-height: 20px; margin: 0 0 16px 0;">
37
+ <%= I18n.t("passkey_auth.magic_link_mailer.login_link.ignore") %>
38
+ </p>
39
+
40
+ <hr style="border: none; border-top: 1px solid #e5e5e5; margin: 32px 0;">
41
+
42
+ <p style="color: #9a9a9a; font-size: 12px; line-height: 18px; margin: 0;">
43
+ <%= I18n.t("passkey_auth.magic_link_mailer.login_link.security_notice") %>
44
+ </p>
45
+ </div>
@@ -0,0 +1,19 @@
1
+ <div class="passkey-auth-container">
2
+ <h1><%= I18n.t("passkey_auth.magic_links.new.title") %></h1>
3
+ <p><%= I18n.t("passkey_auth.magic_links.new.description") %></p>
4
+
5
+ <%= form_with url: magic_links_path do |form| %>
6
+ <div class="form-group">
7
+ <%= form.label :email %>
8
+ <%= form.email_field :email,
9
+ required: true,
10
+ autofocus: true,
11
+ placeholder: I18n.t("passkey_auth.magic_links.new.email_placeholder"),
12
+ value: @email || params[:email] %>
13
+ </div>
14
+
15
+ <%= form.submit I18n.t("passkey_auth.magic_links.new.submit_button"), class: "btn btn-primary" %>
16
+ <% end %>
17
+
18
+ <p><%= link_to I18n.t("passkey_auth.magic_links.new.back_to_login"), new_session_path %></p>
19
+ </div>
@@ -0,0 +1,21 @@
1
+ <div class="passkey-auth-container">
2
+ <h1><%= I18n.t("passkey_auth.magic_links.verify_code.title") %></h1>
3
+ <p><%= I18n.t("passkey_auth.magic_links.verify_code.description", email: @email) %></p>
4
+
5
+ <%= form_with url: verify_with_code_magic_links_path do |form| %>
6
+ <%= form.hidden_field :email, value: @email %>
7
+
8
+ <div class="form-group">
9
+ <%= form.label :code, I18n.t("passkey_auth.magic_links.verify_code.code_label") %>
10
+ <%= form.text_field :code,
11
+ required: true,
12
+ autofocus: true,
13
+ autocomplete: "one-time-code",
14
+ placeholder: "XXXX-XXXX" %>
15
+ </div>
16
+
17
+ <%= form.submit I18n.t("passkey_auth.magic_links.verify_code.submit_button"), class: "btn btn-primary" %>
18
+ <% end %>
19
+
20
+ <p><%= I18n.t("passkey_auth.magic_links.verify_code.cant_find_code_html", request_new_url: new_magic_link_path(email: @email)) %></p>
21
+ </div>
@@ -0,0 +1,53 @@
1
+ en:
2
+ passkey_auth:
3
+ sessions:
4
+ new:
5
+ title: "Sign In"
6
+ email_placeholder: "Enter your email"
7
+ submit: "Continue with Email"
8
+ invalid_credentials: "Invalid credentials"
9
+
10
+ magic_links:
11
+ new:
12
+ title: "Sign In with Magic Link"
13
+ description: "Enter your email to receive a sign-in link"
14
+ email_placeholder: "your@email.com"
15
+ submit_button: "Send Magic Link"
16
+ back_to_login: "Back to sign in"
17
+ verify_code:
18
+ title: "Enter Your Code"
19
+ description: "We sent a code to %{email}"
20
+ code_label: "Verification Code"
21
+ submit_button: "Verify Code"
22
+ cant_find_code_html: "Can't find the code? <a href='%{request_new_url}'>Request a new one</a>"
23
+ success: "Successfully signed in"
24
+ success_with_passkey_prompt: "Successfully signed in. Consider setting up a passkey for faster sign-in next time."
25
+ errors:
26
+ rate_limited: "Too many requests. Please wait a moment before trying again."
27
+ user_not_found: "No account found with that email address."
28
+ send_failed: "Failed to send magic link. Please try again."
29
+ invalid_code: "Invalid or expired code"
30
+ expired: "This link has expired. Please request a new one."
31
+ already_used: "This link has already been used"
32
+ generic: "Something went wrong. Please try again."
33
+
34
+ magic_link_mailer:
35
+ login_link:
36
+ subject: "Your login code is %{code}"
37
+ title: "Sign In"
38
+ greeting: "Hi %{name},"
39
+ body: "You requested a sign-in link. Click the button below to sign in:"
40
+ button: "Sign In"
41
+ code_prompt: "Or enter this code if prompted:"
42
+ copy_link: "Or copy and paste this link into your browser:"
43
+ expires: "This link and code will expire in %{minutes} minutes."
44
+ ignore: "If you didn't request this link, you can safely ignore this email."
45
+ security_notice: "For security reasons, this link can only be used once. If you need another link, please request a new one."
46
+
47
+ webauthn:
48
+ session_expired: "Session expired. Please refresh the page and try again."
49
+ verification_failed: "Passkey verification failed"
50
+ authentication_failed: "Passkey verification failed. Please try signing in with your email."
51
+ passkey_not_registered: "This passkey is not registered. Please sign in with your email and register this passkey."
52
+ unexpected_error: "An unexpected error occurred. Please try signing in with your email."
53
+ credential_deleted: "Passkey deleted successfully"
data/config/routes.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ PasskeyAuth::Engine.routes.draw do
4
+ resource :session, only: %i[new show create destroy]
5
+
6
+ resources :magic_links, only: %i[new create] do
7
+ collection do
8
+ get :verify_code
9
+ post :verify_with_code
10
+ end
11
+ end
12
+
13
+ get "magic_links/:token", to: "magic_links#show", as: :verify_magic_link
14
+
15
+ namespace :webauthn do
16
+ resources :credentials, only: %i[index create destroy] do
17
+ collection do
18
+ resource :challenge, only: %i[create], module: :credentials, as: :credentials_challenge
19
+ end
20
+ end
21
+
22
+ resources :authentications, only: %i[index create] do
23
+ collection do
24
+ resource :challenge, only: %i[create], module: :authentications, as: :authentications_challenge
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module PasskeyAuth
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("templates", __dir__)
12
+
13
+ desc "Installs PasskeyAuth and generates necessary migrations and initializer"
14
+
15
+ def copy_migrations
16
+ migration_template "create_passkey_auth_magic_links.rb.erb",
17
+ "db/migrate/create_passkey_auth_magic_links.rb",
18
+ migration_version: migration_version
19
+ migration_template "create_passkey_auth_webauthn_credentials.rb.erb",
20
+ "db/migrate/create_passkey_auth_webauthn_credentials.rb",
21
+ migration_version: migration_version
22
+ migration_template "add_passwordless_to_users.rb.erb",
23
+ "db/migrate/add_passwordless_to_users.rb",
24
+ migration_version: migration_version
25
+ end
26
+
27
+ def create_initializer
28
+ template "initializer.rb", "config/initializers/passkey_auth.rb"
29
+ end
30
+
31
+ def add_routes
32
+ route 'mount PasskeyAuth::Engine => "/auth"'
33
+ end
34
+
35
+ def copy_javascript_files
36
+ # Copy JavaScript controllers from the gem's app/javascript directory
37
+ source_paths << File.expand_path("../../../../app/javascript", __dir__)
38
+ directory "passkey_auth", "app/javascript/passkey_auth"
39
+ end
40
+
41
+ def install_javascript_dependencies
42
+ say "\nInstalling JavaScript dependencies...", :yellow
43
+
44
+ if File.exist?("config/importmap.rb")
45
+ append_to_file "config/importmap.rb" do
46
+ <<~RUBY
47
+
48
+ # PasskeyAuth dependencies
49
+ pin "@github/webauthn-json", to: "https://ga.jspm.io/npm:@github/webauthn-json@2.1.1/dist/esm/webauthn-json.js"
50
+ pin "@rails/request.js", to: "https://ga.jspm.io/npm:@rails/request.js@0.0.9/src/index.js"
51
+ pin "passkey_auth/controllers", to: "passkey_auth/controllers/index.js"
52
+ pin "passkey_auth/controllers/passkey_authentication_controller"
53
+ pin "passkey_auth/controllers/webauthn_register_controller"
54
+ pin "passkey_auth/controllers/webauthn_auth_controller"
55
+ pin "passkey_auth/lib/passkey"
56
+ RUBY
57
+ end
58
+ say "Added importmap pins for PasskeyAuth", :green
59
+ else
60
+ say "\nWARNING: config/importmap.rb not found.", :yellow
61
+ say "If you're using a different JavaScript bundler (esbuild, webpack, etc.),"
62
+ say "make sure to add @github/webauthn-json as a dependency:", :yellow
63
+ say "\n npm install @github/webauthn-json\n", :cyan
64
+ end
65
+ end
66
+
67
+ def display_post_install_message
68
+ say "\n"
69
+ say "PasskeyAuth installed successfully!", :green
70
+ say "\n"
71
+ say "Next steps:", :yellow
72
+ say " 1. Ensure you have Rails authentication installed (rails generate authentication)"
73
+ say " 2. Add 'include PasskeyAuth::Concerns::Passwordless' to your User model"
74
+ say " 3. Run 'rails db:migrate' to create the necessary tables"
75
+ say " 4. Configure the initializer at config/initializers/passkey_auth.rb"
76
+ say " 5. Register Stimulus controllers in app/javascript/controllers/application.js:"
77
+ say ""
78
+ say " import { PasskeyAuthenticationController, WebauthnRegisterController, WebauthnAuthController }", :cyan
79
+ say " from 'passkey_auth/controllers'", :cyan
80
+ say " application.register('passkey-authentication', PasskeyAuthenticationController)", :cyan
81
+ say " application.register('webauthn-register', WebauthnRegisterController)", :cyan
82
+ say " application.register('webauthn-auth', WebauthnAuthController)", :cyan
83
+ say "\n"
84
+ say "PasskeyAuth works alongside Rails' authentication system.", :yellow
85
+ say "Users can authenticate with passwords (Rails) OR passwordless methods (this gem)."
86
+ say "\n"
87
+ say "See the README for more details: https://github.com/jhubert/passkey_auth"
88
+ say "\n"
89
+ end
90
+
91
+ private
92
+
93
+ def migration_version
94
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,8 @@
1
+ class AddPasswordlessToUsers < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ add_column :users, :webauthn_id, :string
4
+ add_column :users, :magic_link_sent_at, :datetime
5
+
6
+ add_index :users, :webauthn_id, unique: true
7
+ end
8
+ end
@@ -0,0 +1,21 @@
1
+ class CreatePasskeyAuthMagicLinks < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :passkey_auth_magic_links do |t|
4
+ t.references :user, null: false, foreign_key: true
5
+ t.string :token, null: false
6
+ t.string :short_code, null: false
7
+ t.datetime :expires_at, null: false
8
+ t.datetime :used_at
9
+ t.string :ip_address
10
+ t.string :user_agent
11
+ t.string :purpose, default: "login", null: false
12
+
13
+ t.timestamps
14
+ end
15
+
16
+ add_index :passkey_auth_magic_links, :token, unique: true
17
+ add_index :passkey_auth_magic_links, :short_code
18
+ add_index :passkey_auth_magic_links, [:user_id, :expires_at]
19
+ add_index :passkey_auth_magic_links, :expires_at
20
+ end
21
+ end
@@ -0,0 +1,16 @@
1
+ class CreatePasskeyAuthWebauthnCredentials < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :passkey_auth_webauthn_credentials do |t|
4
+ t.references :user, null: false, foreign_key: true
5
+ t.string :external_id, null: false
6
+ t.string :public_key, null: false
7
+ t.string :nickname, null: false
8
+ t.integer :sign_count, null: false, default: 0
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :passkey_auth_webauthn_credentials, :external_id, unique: true
14
+ add_index :passkey_auth_webauthn_credentials, [:nickname, :user_id], unique: true
15
+ end
16
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ PasskeyAuth.setup do |config|
4
+ # The name of your User model (default: "User")
5
+ # config.user_model_name = "User"
6
+
7
+ # The email attribute on your User model (default: :email_address)
8
+ # Rails 8 uses :email_address by default
9
+ # If your User model uses :email, set this to :email
10
+ # Or use alias_attribute in your User model: alias_attribute :email, :email_address
11
+ # config.user_email_attribute = :email_address
12
+
13
+ # How long magic links are valid (default: 10.minutes)
14
+ # config.magic_link_expiration = 10.minutes
15
+
16
+ # Rate limit for magic link requests (default: 1.minute)
17
+ # config.magic_link_rate_limit = 1.minute
18
+
19
+ # WebAuthn configuration
20
+ # config.webauthn_origin = ENV.fetch("WEBAUTHN_ORIGIN", "http://localhost:3000")
21
+ # config.webauthn_rp_name = "Your App Name"
22
+
23
+ # Hooks - add custom behavior to passwordless authentication events
24
+ # config.on_magic_link_requested = ->(user, magic_link) { puts "Magic link requested" }
25
+ # config.on_passkey_created = ->(user, credential) { puts "Passkey created" }
26
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PasskeyAuth
4
+ module Concerns
5
+ module Passwordless
6
+ extend ActiveSupport::Concern
7
+
8
+ class AuthenticationError < StandardError; end
9
+
10
+ included do
11
+ has_many :passkey_auth_magic_links,
12
+ class_name: "PasskeyAuth::MagicLink",
13
+ foreign_key: :user_id,
14
+ dependent: :delete_all
15
+
16
+ has_many :passkey_auth_webauthn_credentials,
17
+ class_name: "PasskeyAuth::WebauthnCredential",
18
+ foreign_key: :user_id,
19
+ dependent: :delete_all
20
+
21
+ # Friendly aliases
22
+ alias_method :magic_links, :passkey_auth_magic_links
23
+ alias_method :webauthn_credentials, :passkey_auth_webauthn_credentials
24
+
25
+ validates :webauthn_id, uniqueness: true, allow_blank: true
26
+ end
27
+
28
+ # Magic link authentication methods
29
+ def can_request_magic_link?
30
+ return true if magic_link_sent_at.nil?
31
+
32
+ # Rate limit based on configuration
33
+ magic_link_sent_at < PasskeyAuth.magic_link_rate_limit.ago
34
+ end
35
+
36
+ def create_magic_link!(purpose: "login")
37
+ return nil unless can_request_magic_link?
38
+
39
+ transaction do
40
+ # Expire any existing active magic links
41
+ magic_links.active.update_all(used_at: Time.current)
42
+
43
+ # Create new magic link
44
+ magic_link = magic_links.create!(purpose: purpose)
45
+
46
+ # Update rate limiting timestamp
47
+ update!(magic_link_sent_at: Time.current)
48
+
49
+ magic_link
50
+ end
51
+ end
52
+
53
+ def send_magic_link!
54
+ unless can_request_magic_link?
55
+ return { success: false, error: "rate_limited" }
56
+ end
57
+
58
+ magic_link = create_magic_link!
59
+
60
+ if magic_link
61
+ PasskeyAuth::MagicLinkMailer.login_link(magic_link).deliver_later
62
+
63
+ # Call hook if configured
64
+ PasskeyAuth.on_magic_link_requested&.call(self, magic_link)
65
+
66
+ { success: true, magic_link: magic_link }
67
+ else
68
+ { success: false, error: "creation_failed" }
69
+ end
70
+ rescue StandardError => e
71
+ Rails.logger.error "MagicLink error: #{e.message}"
72
+ { success: false, error: "unexpected_error" }
73
+ end
74
+
75
+ class_methods do
76
+ def authenticate_with_webauthn(credential, challenge)
77
+ # Find the stored credential by external_id
78
+ stored_credential = PasskeyAuth::WebauthnCredential.find_by(external_id: credential.id)
79
+
80
+ raise AuthenticationError, "Passkey not registered" unless stored_credential
81
+
82
+ # Verify the credential against the stored_credential
83
+ credential.verify(
84
+ challenge,
85
+ public_key: stored_credential.public_key,
86
+ sign_count: stored_credential.sign_count
87
+ )
88
+
89
+ # Update the sign count to prevent replay attacks
90
+ stored_credential.update!(sign_count: credential.sign_count)
91
+
92
+ # Return the user
93
+ stored_credential.user
94
+ end
95
+ end
96
+
97
+ def passwordless?
98
+ webauthn_credentials.exists? || magic_links.active.exists?
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+ require "webauthn"
5
+
6
+ module PasskeyAuth
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace PasskeyAuth
9
+
10
+ config.generators do |g|
11
+ g.test_framework :minitest
12
+ g.fixture_replacement :minitest
13
+ end
14
+
15
+ initializer "passkey_auth.set_configs" do
16
+ # Set WebAuthn configuration based on Rails environment
17
+ PasskeyAuth.webauthn_origin ||= begin
18
+ if Rails.env.production?
19
+ ENV.fetch("WEBAUTHN_ORIGIN", "https://example.com")
20
+ else
21
+ "http://localhost:3000"
22
+ end
23
+ end
24
+
25
+ # Configure WebAuthn gem
26
+ WebAuthn.configure do |config|
27
+ config.origin = PasskeyAuth.webauthn_origin
28
+ config.rp_name = PasskeyAuth.webauthn_rp_name
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PasskeyAuth
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "passkey_auth/version"
4
+ require_relative "passkey_auth/engine"
5
+ require_relative "passkey_auth/concerns/passwordless"
6
+
7
+ module PasskeyAuth
8
+ class Error < StandardError; end
9
+
10
+ # Configuration
11
+ mattr_accessor :user_model_name, default: "User"
12
+ mattr_accessor :user_email_attribute, default: :email_address # Rails 8 default
13
+ mattr_accessor :magic_link_expiration, default: 10.minutes
14
+ mattr_accessor :magic_link_rate_limit, default: 1.minute
15
+ mattr_accessor :webauthn_origin
16
+ mattr_accessor :webauthn_rp_name, default: "PasskeyAuth"
17
+
18
+ # Hooks - allow apps to add custom behavior
19
+ mattr_accessor :on_magic_link_requested, default: nil
20
+ mattr_accessor :on_passkey_created, default: nil
21
+
22
+ def self.setup
23
+ yield self
24
+ end
25
+
26
+ def self.user_class
27
+ user_model_name.constantize
28
+ end
29
+
30
+ # Helper to get the email attribute value from a user
31
+ def self.user_email(user)
32
+ user.public_send(user_email_attribute)
33
+ end
34
+ end
@@ -0,0 +1,4 @@
1
+ module PasskeyAuth
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end