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,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PasskeyAuth
4
+ module Webauthn
5
+ module Authentications
6
+ class ChallengesController < PasskeyAuth::ApplicationController
7
+ # Expects Rails Authentication concern to provide:
8
+ # - allow_unauthenticated_access
9
+
10
+ allow_unauthenticated_access
11
+
12
+ before_action :load_user
13
+
14
+ def create
15
+ if @user
16
+ # Prepare the needed data for a challenge
17
+ options = ::WebAuthn::Credential.options_for_get(allow: @user.webauthn_credentials.pluck(:external_id))
18
+ else
19
+ # For conditional UI, we create options for authentication without knowing the user ahead of time
20
+ # This allows the browser to prompt with available passkeys
21
+ options = ::WebAuthn::Credential.options_for_get(user_verification: "required")
22
+ end
23
+
24
+ # Generate the challenge and save it into the session
25
+ session[:webauthn_authentication_challenge] = options.challenge
26
+
27
+ respond_to do |format|
28
+ format.json { render json: options }
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def load_user
35
+ @user = PasskeyAuth.user_class.find_by(id: session[:webauthn_authentication_user_id])
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PasskeyAuth
4
+ module Webauthn
5
+ class AuthenticationsController < PasskeyAuth::ApplicationController
6
+ # Expects Rails Authentication concern to provide:
7
+ # - allow_unauthenticated_access
8
+ # - start_new_session_for(user)
9
+ # - after_authentication_url
10
+
11
+ allow_unauthenticated_access only: :create
12
+
13
+ def index
14
+ end
15
+
16
+ def create
17
+ result = authenticate_webauthn
18
+
19
+ if result[:success]
20
+ session.delete(:webauthn_authentication_user_id)
21
+
22
+ respond_to do |format|
23
+ format.json { render json: result }
24
+ format.html { redirect_to result[:redirect_url], status: :see_other }
25
+ end
26
+ else
27
+ render json: result, status: :unprocessable_content
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def authenticate_webauthn
34
+ webauthn_credential = ::WebAuthn::Credential.from_get(params[:credential])
35
+
36
+ # Verify the credential using the conditional challenge
37
+ challenge = session[:webauthn_authentication_challenge]
38
+ unless challenge
39
+ return { error: I18n.t("passkey_auth.webauthn.session_expired") }
40
+ end
41
+
42
+ user = PasskeyAuth.user_class.authenticate_with_webauthn(webauthn_credential, challenge)
43
+ start_new_session_for user
44
+
45
+ # Return success with redirect URL
46
+ { success: true, redirect_url: after_authentication_url }
47
+ rescue ::WebAuthn::Error => e
48
+ Rails.logger.error "WebAuthn verification failed: #{e.message}"
49
+ { error: I18n.t("passkey_auth.webauthn.authentication_failed") }
50
+ rescue PasskeyAuth::Concerns::Passwordless::AuthenticationError => e
51
+ Rails.logger.error "Webauthn Authentication failed: #{e.message}"
52
+ { error: I18n.t("passkey_auth.webauthn.passkey_not_registered") }
53
+ rescue => e
54
+ Rails.logger.error "Unexpected error during conditional authentication: #{e.message}"
55
+ Rails.logger.error e.backtrace.join("\n")
56
+ { error: I18n.t("passkey_auth.webauthn.unexpected_error") }
57
+ ensure
58
+ session.delete(:webauthn_authentication_challenge)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PasskeyAuth
4
+ module Webauthn
5
+ module Credentials
6
+ class ChallengesController < PasskeyAuth::ApplicationController
7
+ # Expects Rails Authentication concern to provide:
8
+ # - allow_unauthenticated_access
9
+ # - current_user (may be nil if unauthenticated)
10
+
11
+ allow_unauthenticated_access
12
+
13
+ def create
14
+ user = current_user || find_user_by_session_id
15
+
16
+ # Generate WebAuthn ID if the user does not have any yet
17
+ user.update(webauthn_id: ::WebAuthn.generate_user_id) unless user.webauthn_id
18
+
19
+ user_email = PasskeyAuth.user_email(user)
20
+ webauthn_name = params[:name].presence || user.try(:name) || user_email
21
+
22
+ # Prepare the needed data for a challenge
23
+ create_options = ::WebAuthn::Credential.options_for_create(
24
+ user: {
25
+ id: user.webauthn_id,
26
+ display_name: webauthn_name,
27
+ name: user_email # unique name for auth
28
+ },
29
+ exclude: user.webauthn_credentials.pluck(:external_id),
30
+ authenticator_selection: {
31
+ authenticator_attachment: "platform",
32
+ user_verification: "preferred",
33
+ resident_key: "required"
34
+ }
35
+ )
36
+
37
+ # Generate the challenge and save it into the session
38
+ session[:webauthn_registration_challenge] = create_options.challenge
39
+
40
+ respond_to do |format|
41
+ format.json { render json: create_options }
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def find_user_by_session_id
48
+ PasskeyAuth.user_class.find(session[:webauthn_credential_user_id])
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PasskeyAuth
4
+ module Webauthn
5
+ class CredentialsController < PasskeyAuth::ApplicationController
6
+ # Expects Rails Authentication concern to provide:
7
+ # - current_user
8
+
9
+ def index
10
+ @credentials = current_user.webauthn_credentials.order(created_at: :desc)
11
+ end
12
+
13
+ def create
14
+ webauthn_credential = ::WebAuthn::Credential.from_create(params[:credential])
15
+
16
+ # Verify the credential
17
+ challenge = session[:webauthn_registration_challenge]
18
+ unless challenge
19
+ return render json: { error: I18n.t("passkey_auth.webauthn.session_expired") }, status: :unprocessable_content
20
+ end
21
+
22
+ begin
23
+ webauthn_credential.verify(challenge)
24
+
25
+ # Ensure user has a webauthn_id
26
+ unless current_user.webauthn_id
27
+ current_user.update!(webauthn_id: ::WebAuthn.generate_user_id)
28
+ end
29
+
30
+ # Store the credential
31
+ credential = current_user.webauthn_credentials.create!(
32
+ external_id: webauthn_credential.id,
33
+ nickname: params[:nickname] || "Passkey",
34
+ public_key: webauthn_credential.public_key,
35
+ sign_count: webauthn_credential.sign_count
36
+ )
37
+
38
+ # Call hook if configured
39
+ PasskeyAuth.on_passkey_created&.call(current_user, credential)
40
+
41
+ render json: { success: true }
42
+ rescue ::WebAuthn::Error => e
43
+ Rails.logger.error "WebAuthn verification failed: #{e.message}"
44
+ render json: { error: I18n.t("passkey_auth.webauthn.verification_failed") }, status: :unprocessable_content
45
+ ensure
46
+ session.delete(:webauthn_registration_challenge)
47
+ end
48
+ end
49
+
50
+ def destroy
51
+ credential = current_user.webauthn_credentials.find(params[:id])
52
+ credential.destroy
53
+
54
+ redirect_to webauthn_credentials_path, notice: I18n.t("passkey_auth.webauthn.credential_deleted")
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,4 @@
1
+ // Export all PasskeyAuth Stimulus controllers
2
+ export { default as PasskeyAuthenticationController } from "./passkey_authentication_controller";
3
+ export { default as WebauthnRegisterController } from "./webauthn_register_controller";
4
+ export { default as WebauthnAuthController } from "./webauthn_auth_controller";
@@ -0,0 +1,156 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import * as WebAuthnJSON from "@github/webauthn-json";
3
+ import { post } from "@rails/request.js";
4
+
5
+ export default class extends Controller {
6
+ static targets = ["signinButton"];
7
+ static values = {
8
+ challengePath: String,
9
+ authenticationPath: String
10
+ };
11
+
12
+ connect() {
13
+ if (!("PublicKeyCredential" in window)) {
14
+ this.hidePasskeyButton();
15
+ console.error("Passkeys not supported in this browser");
16
+ return;
17
+ }
18
+
19
+ // Prefetch challenge early to preserve user gesture later
20
+ this.challengePromise = this.fetchChallenge();
21
+
22
+ this.abortController = null;
23
+ this.busy = false;
24
+ }
25
+
26
+ disconnect() {
27
+ // Ensure we cancel any pending ceremony when navigating away
28
+ this.abortActiveCeremony();
29
+ }
30
+
31
+ async fetchChallenge() {
32
+ try {
33
+ const resp = await post(this.challengePathValue);
34
+ if (!resp.ok) throw new Error("Challenge endpoint failed");
35
+ return await resp.json;
36
+ } catch (e) {
37
+ console.error("Failed to fetch challenge:", e);
38
+ return null; // degrade gracefully; button can fallback or show error
39
+ }
40
+ }
41
+
42
+ hidePasskeyButton() {
43
+ this.element.style.display = "none";
44
+ }
45
+
46
+ enableButton() {
47
+ if (this.hasSigninButtonTarget) {
48
+ this.signinButtonTarget.disabled = false;
49
+ }
50
+ }
51
+
52
+ disableButton() {
53
+ if (this.hasSigninButtonTarget) {
54
+ this.signinButtonTarget.disabled = true;
55
+ }
56
+ }
57
+
58
+ async signIn(event) {
59
+ event.preventDefault();
60
+ await this.initiateSignIn("required");
61
+ }
62
+
63
+ abortActiveCeremony() {
64
+ if (this.abortController) {
65
+ try { this.abortController.abort(); } catch {}
66
+ this.abortController = null;
67
+ }
68
+ this.busy = false;
69
+ this.enableButton();
70
+ }
71
+
72
+ async initiateSignIn(mediation = "required") {
73
+ if (!("PublicKeyCredential" in window)) return;
74
+ if (this.busy) return;
75
+
76
+ // If a conditional ceremony might be running, cancel it before starting a required one
77
+ this.abortActiveCeremony();
78
+
79
+ this.busy = true;
80
+ this.abortController = new AbortController();
81
+ this.disableButton();
82
+
83
+ const { signal } = this.abortController;
84
+
85
+ try {
86
+ // Use the pre-fetched challenge if available; else fetch now
87
+ const publicKey = (await this.challengePromise) || (await this.fetchChallenge());
88
+ if (!publicKey) throw new Error("No challenge options available");
89
+
90
+ // Only pass 'mediation' if we're actually using conditional
91
+ const opts = { publicKey, signal };
92
+ if (mediation === "conditional") opts.mediation = "conditional";
93
+
94
+ // IMPORTANT: call immediately (no more awaits before this point)
95
+ const credential = await WebAuthnJSON.get(opts);
96
+
97
+ if (credential) {
98
+ await this.submitCredentialForVerification(credential);
99
+ }
100
+ } catch (error) {
101
+ console.error("Passkey authentication error:", error);
102
+ // Common fast-fails you might want to ignore quietly for conditional UI
103
+ if (error.name === "AbortError") {
104
+ // expected if we navigated or started another ceremony
105
+ } else if (error.name === "NotAllowedError" && mediation === "conditional") {
106
+ // user dismissed sheet; fine to ignore
107
+ } else if (error.name === "SecurityError") {
108
+ this.showErrorMessage("Passkey authentication isn't allowed in this context. Try another sign-in method.");
109
+ } else if (error.name === "InvalidStateError") {
110
+ this.showErrorMessage("This passkey is in an invalid state. Try another method or re-enroll.");
111
+ } else {
112
+ this.showErrorMessage("Passkey sign-in failed. Please try another method.");
113
+ }
114
+ } finally {
115
+ this.busy = false;
116
+ this.abortController = null;
117
+ // Refresh the challenge for the *next* attempt to avoid replay issues
118
+ this.challengePromise = this.fetchChallenge();
119
+ this.enableButton();
120
+ }
121
+ }
122
+
123
+ async submitCredentialForVerification(credential) {
124
+ try {
125
+ const response = await post(this.authenticationPathValue, { body: { credential } });
126
+ const result = await response.json;
127
+
128
+ if (response.ok && result.success) {
129
+ window.Turbo.visit(result.redirect_url);
130
+ } else {
131
+ this.showErrorMessage(result.error || "Authentication failed");
132
+
133
+ // Signal to browser that this credential wasn't recognized
134
+ if (PublicKeyCredential.signalUnknownCredential) {
135
+ PublicKeyCredential.signalUnknownCredential({
136
+ rpId: window.location.hostname,
137
+ credentialId: credential.id
138
+ });
139
+ }
140
+ }
141
+ } catch (error) {
142
+ console.error("Credential verification error:", error);
143
+ this.showErrorMessage("Please try signing in another way.");
144
+ }
145
+ }
146
+
147
+ showErrorMessage(message) {
148
+ let friendly = message;
149
+ if (message && message.includes("credential not found")) {
150
+ friendly = "This passkey isn't registered with your account. Sign in another way, then add this passkey in Security settings.";
151
+ }
152
+
153
+ // Display alert - customize this based on your app's alert system
154
+ alert(friendly);
155
+ }
156
+ }
@@ -0,0 +1,40 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import { authenticate } from "../lib/passkey";
3
+
4
+ export default class extends Controller {
5
+ static values = { callbackUrl: String };
6
+
7
+ connect() {
8
+ this.abortController = null;
9
+ }
10
+
11
+ async submit(event) {
12
+ event.preventDefault();
13
+
14
+ // Cancel any in-flight attempt
15
+ this.abortController?.abort();
16
+ this.abortController = new AbortController();
17
+
18
+ try {
19
+ const result = await authenticate({
20
+ authUrl: this.element.action,
21
+ callbackUrl: this.callbackUrlValue,
22
+ signal: this.abortController.signal
23
+ });
24
+
25
+ if (result.aborted) {
26
+ // User canceled the authentication
27
+ return;
28
+ }
29
+
30
+ // If not redirected, callbackRes likely contained a turbo-stream update already
31
+ } catch (err) {
32
+ console.error("Passkey authentication failed:", err);
33
+ alert("Authentication failed. Please try again.");
34
+ }
35
+ }
36
+
37
+ disconnect() {
38
+ this.abortController?.abort();
39
+ }
40
+ }
@@ -0,0 +1,70 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import { register } from "../lib/passkey";
3
+
4
+ export default class extends Controller {
5
+ static targets = ["nickname"];
6
+ static values = {
7
+ callbackUrl: String,
8
+ challengeUrl: String,
9
+ nameSourceSelector: String
10
+ };
11
+
12
+ connect() {
13
+ this.abortController = null;
14
+ }
15
+
16
+ async submit(event) {
17
+ event.preventDefault();
18
+
19
+ this.element.classList.add('opacity-50');
20
+ const submitButton = this.element.querySelector('[type="submit"]');
21
+ submitButton?.setAttribute('disabled', 'disabled');
22
+
23
+ // Cancel any in-flight attempt
24
+ this.abortController?.abort();
25
+ this.abortController = new AbortController();
26
+
27
+ const challengeUrl = this.challengeUrlValue || this.element.action;
28
+ let userName = null;
29
+
30
+ if (this.nameSourceSelectorValue) {
31
+ const nameSource = document.querySelector(this.nameSourceSelectorValue);
32
+ if (nameSource) {
33
+ userName = nameSource.value;
34
+ }
35
+ }
36
+
37
+ try {
38
+ const response = await register({
39
+ challengeUrl: challengeUrl,
40
+ callbackUrl: this.callbackUrlValue,
41
+ signal: this.abortController.signal,
42
+ nickname: this.hasNicknameTarget ? this.nicknameTarget.value : null,
43
+ userName: userName
44
+ });
45
+
46
+ if (response.aborted) {
47
+ console.warn("Passkey registration request was aborted");
48
+ return;
49
+ }
50
+ } catch (error) {
51
+ if (error.name == "InvalidStateError") {
52
+ alert("This security key is already registered to your account");
53
+ } else if (error.name == "NotSupportedError") {
54
+ alert("Your browser doesn't support security keys. Please try a modern browser.");
55
+ } else if (error.message) {
56
+ alert(`Registration failed: ${error.message}`);
57
+ }
58
+ console.error("Passkey error:", error);
59
+ return;
60
+ } finally {
61
+ this.abortController = null;
62
+ this.element.classList.remove('opacity-50');
63
+ submitButton?.removeAttribute('disabled');
64
+ }
65
+ }
66
+
67
+ disconnect() {
68
+ this.abortController?.abort();
69
+ }
70
+ }
@@ -0,0 +1,3 @@
1
+ // Main entry point for PasskeyAuth JavaScript
2
+ export * from "./controllers/index";
3
+ export * as Passkey from "./lib/passkey";
@@ -0,0 +1,103 @@
1
+ import * as WebAuthnJSON from "@github/webauthn-json";
2
+ import { post } from "@rails/request.js";
3
+
4
+ export async function register({ challengeUrl, callbackUrl, signal, nickname, userName } = {}) {
5
+ // 1) Ask server for PublicKey options
6
+ const credentialRes = await post(challengeUrl, {
7
+ body: { name: userName },
8
+ contentType: "application/json",
9
+ responseKind: "json",
10
+ signal
11
+ });
12
+ if (!credentialRes.ok) throw new Error(`Register options HTTP ${credentialRes.status}`);
13
+
14
+ const publicKey = await credentialRes.json;
15
+
16
+ // 2) Perform WebAuthn assertion (user presence / verification)
17
+ let credential;
18
+ try {
19
+ credential = await WebAuthnJSON.create({ publicKey });
20
+ } catch (e) {
21
+ if (e?.name === "NotAllowedError" || e?.name === "AbortError") {
22
+ // benign: user canceled / dismissed prompt
23
+ return { aborted: true };
24
+ }
25
+ throw e;
26
+ }
27
+
28
+ if (!nickname) {
29
+ nickname = await generatePasskeyName(credential);
30
+ }
31
+
32
+ // 3) Send credential to server (Turbo Stream capable)
33
+ const callbackRes = await post(callbackUrl, {
34
+ body: JSON.stringify({ nickname, credential }),
35
+ contentType: "application/json",
36
+ responseKind: "turbo-stream",
37
+ signal
38
+ });
39
+
40
+ return { aborted: false, response: callbackRes };
41
+ }
42
+
43
+ export async function authenticate({ authUrl, callbackUrl, signal } = {}) {
44
+ // 1) Ask server for PublicKey options
45
+ const authRes = await post(authUrl, {
46
+ contentType: "application/json",
47
+ responseKind: "json",
48
+ signal
49
+ });
50
+ if (!authRes.ok) throw new Error(`Auth options HTTP ${authRes.status}`);
51
+
52
+ const publicKey = await authRes.json;
53
+
54
+ // 2) Perform WebAuthn assertion (user presence / verification)
55
+ let credential;
56
+ try {
57
+ credential = await WebAuthnJSON.get({ publicKey });
58
+ } catch (e) {
59
+ if (e?.name === "NotAllowedError" || e?.name === "AbortError") {
60
+ // benign: user canceled / dismissed prompt
61
+ return { aborted: true };
62
+ }
63
+ throw e;
64
+ }
65
+
66
+ // 3) Send credential to server (Turbo Stream capable)
67
+ const callbackRes = await post(callbackUrl, {
68
+ body: JSON.stringify({ credential }),
69
+ contentType: "application/json",
70
+ responseKind: "turbo-stream",
71
+ signal
72
+ });
73
+
74
+ if (callbackRes.redirected) {
75
+ window.Turbo.visit(callbackRes.response.url);
76
+ }
77
+
78
+ return { aborted: false, response: callbackRes };
79
+ }
80
+
81
+ async function generatePasskeyName(credential) {
82
+ const attachment = credential.authenticatorAttachment; // 'platform' or 'cross-platform'
83
+ const ua = navigator.userAgent.toLowerCase();
84
+ let name = '';
85
+
86
+ if (attachment === 'platform') {
87
+ if (ua.includes('windows')) {
88
+ name = 'Windows Hello';
89
+ } else if (ua.includes('macintosh') || ua.includes('mac os x')) {
90
+ name = 'Mac Touch ID';
91
+ } else if (ua.includes('iphone') || ua.includes('ipad')) {
92
+ name = ua.includes('iphone') ? 'iPhone Touch ID' : 'iPad Touch ID';
93
+ } else if (ua.includes('android')) {
94
+ name = 'Android Biometric';
95
+ } else {
96
+ name = 'Platform Authenticator';
97
+ }
98
+ } else {
99
+ name = 'External Security Key'; // e.g., YubiKey or 1Password
100
+ }
101
+
102
+ return name;
103
+ }
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PasskeyAuth
4
+ class ApplicationMailer < ActionMailer::Base
5
+ default from: "noreply@example.com"
6
+ layout "mailer"
7
+ end
8
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PasskeyAuth
4
+ class MagicLinkMailer < ApplicationMailer
5
+ def login_link(magic_link)
6
+ @magic_link = magic_link
7
+ @user = magic_link.user
8
+ @url = verify_magic_link_url(token: magic_link.token)
9
+ @expires_in = ((magic_link.expires_at - Time.current) / 60).round
10
+ @short_code = MagicLink::ShortCode.format(@magic_link.short_code)
11
+
12
+ subject = I18n.t("passkey_auth.magic_link_mailer.login_link.subject", code: @short_code)
13
+
14
+ mail(to: PasskeyAuth.user_email(@user), subject: subject)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PasskeyAuth
4
+ class MagicLink
5
+ class ShortCode
6
+ # Based on the Crockford alphabet: https://www.crockford.com/base32.html
7
+ ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ".freeze
8
+ CHARS = ALPHABET.chars.freeze
9
+ BASE = CHARS.size
10
+ DEFAULT_LENGTH = 8
11
+
12
+ def self.generate(length = DEFAULT_LENGTH)
13
+ Array.new(length) { CHARS[SecureRandom.random_number(BASE)] }.join
14
+ end
15
+
16
+ # Format code for display (e.g., "1234-5678")
17
+ def self.format(code)
18
+ return nil if code.blank?
19
+
20
+ sprintf("%s%s%s%s-%s%s%s%s", *code.chars)
21
+ end
22
+
23
+ # Clean user input by removing any formatting and uppercase
24
+ def self.clean(code)
25
+ code.to_s.upcase.gsub(/[^A-Z0-9]/, "")
26
+ end
27
+ end
28
+ end
29
+ end