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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +30 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +334 -0
- data/Rakefile +12 -0
- data/app/controllers/passkey_auth/application_controller.rb +13 -0
- data/app/controllers/passkey_auth/magic_links_controller.rb +120 -0
- data/app/controllers/passkey_auth/webauthn/authentications/challenges_controller.rb +40 -0
- data/app/controllers/passkey_auth/webauthn/authentications_controller.rb +62 -0
- data/app/controllers/passkey_auth/webauthn/credentials/challenges_controller.rb +53 -0
- data/app/controllers/passkey_auth/webauthn/credentials_controller.rb +58 -0
- data/app/javascript/passkey_auth/controllers/index.js +4 -0
- data/app/javascript/passkey_auth/controllers/passkey_authentication_controller.js +156 -0
- data/app/javascript/passkey_auth/controllers/webauthn_auth_controller.js +40 -0
- data/app/javascript/passkey_auth/controllers/webauthn_register_controller.js +70 -0
- data/app/javascript/passkey_auth/index.js +3 -0
- data/app/javascript/passkey_auth/lib/passkey.js +103 -0
- data/app/mailers/passkey_auth/application_mailer.rb +8 -0
- data/app/mailers/passkey_auth/magic_link_mailer.rb +17 -0
- data/app/models/passkey_auth/magic_link/short_code.rb +29 -0
- data/app/models/passkey_auth/magic_link.rb +92 -0
- data/app/models/passkey_auth/webauthn_credential.rb +14 -0
- data/app/views/passkey_auth/magic_link_mailer/login_link.html.erb +45 -0
- data/app/views/passkey_auth/magic_links/new.html.erb +19 -0
- data/app/views/passkey_auth/magic_links/verify_code.html.erb +21 -0
- data/config/locales/en.yml +53 -0
- data/config/routes.rb +28 -0
- data/lib/generators/passkey_auth/install/install_generator.rb +98 -0
- data/lib/generators/passkey_auth/install/templates/add_passwordless_to_users.rb.erb +8 -0
- data/lib/generators/passkey_auth/install/templates/create_passkey_auth_magic_links.rb.erb +21 -0
- data/lib/generators/passkey_auth/install/templates/create_passkey_auth_webauthn_credentials.rb.erb +16 -0
- data/lib/generators/passkey_auth/install/templates/initializer.rb +26 -0
- data/lib/passkey_auth/concerns/passwordless.rb +102 -0
- data/lib/passkey_auth/engine.rb +32 -0
- data/lib/passkey_auth/version.rb +5 -0
- data/lib/passkey_auth.rb +34 -0
- data/sig/passkey_auth.rbs +4 -0
- 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,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,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
|