maquina-generators 0.7.0 → 0.8.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +53 -0
  3. data/lib/generators/maquina/clave/templates/app/views/sessions/new.html.erb.tt +2 -0
  4. data/lib/generators/maquina/passkeys/USAGE +18 -0
  5. data/lib/generators/maquina/passkeys/passkeys_generator.rb +259 -0
  6. data/lib/generators/maquina/passkeys/templates/app/controllers/concerns/passkey_challenge.rb +42 -0
  7. data/lib/generators/maquina/passkeys/templates/app/controllers/concerns/passkey_reauthentication.rb +26 -0
  8. data/lib/generators/maquina/passkeys/templates/app/controllers/passkey_challenges_controller.rb +24 -0
  9. data/lib/generators/maquina/passkeys/templates/app/controllers/passkeys_controller.rb +68 -0
  10. data/lib/generators/maquina/passkeys/templates/app/controllers/session/passkey_challenges_controller.rb +18 -0
  11. data/lib/generators/maquina/passkeys/templates/app/controllers/session/passkeys_controller.rb +55 -0
  12. data/lib/generators/maquina/passkeys/templates/app/javascript/controllers/passkey_registration_controller.js +109 -0
  13. data/lib/generators/maquina/passkeys/templates/app/javascript/controllers/passkey_sign_in_controller.js +113 -0
  14. data/lib/generators/maquina/passkeys/templates/app/models/passkey.rb +36 -0
  15. data/lib/generators/maquina/passkeys/templates/app/views/passkeys/index.html.erb +77 -0
  16. data/lib/generators/maquina/passkeys/templates/app/views/sessions/_passkey.html.erb +23 -0
  17. data/lib/generators/maquina/passkeys/templates/config/initializers/webauthn.rb +23 -0
  18. data/lib/generators/maquina/passkeys/templates/config/locales/passkeys.en.yml +46 -0
  19. data/lib/generators/maquina/passkeys/templates/config/locales/passkeys.es.yml +46 -0
  20. data/lib/generators/maquina/passkeys/templates/migration_add_webauthn_id_to_users.rb.tt +6 -0
  21. data/lib/generators/maquina/passkeys/templates/migration_create_passkeys.rb.tt +20 -0
  22. data/lib/generators/maquina/passkeys/templates/test/integration/passkeys_test.rb +99 -0
  23. data/lib/generators/maquina/passkeys/templates/test/test_helpers/webauthn_test_helper.rb +38 -0
  24. data/lib/maquina_generators/version.rb +1 -1
  25. data/test/generators/maquina/clave_generator_test.rb +13 -2
  26. data/test/generators/maquina/passkeys_generator_test.rb +253 -0
  27. data/test/tmp/Gemfile +1 -14
  28. data/test/tmp/app/controllers/application_controller.rb +3 -0
  29. data/test/tmp/app/controllers/concerns/authentication.rb +63 -0
  30. data/test/tmp/app/controllers/registration/verification_resends_controller.rb +38 -0
  31. data/test/tmp/app/controllers/registration/verifications_controller.rb +64 -0
  32. data/test/tmp/app/controllers/registrations_controller.rb +63 -0
  33. data/test/tmp/app/controllers/session/verification_resends_controller.rb +44 -0
  34. data/test/tmp/app/controllers/session/verifications_controller.rb +64 -0
  35. data/test/tmp/app/controllers/sessions_controller.rb +56 -0
  36. data/test/tmp/app/helpers/authentication_helper.rb +20 -0
  37. data/test/tmp/app/jobs/authentication_cleanup_job.rb +13 -0
  38. data/test/tmp/app/mailers/verification_mailer.rb +15 -0
  39. data/test/tmp/app/models/account.rb +5 -0
  40. data/test/tmp/app/models/current.rb +5 -0
  41. data/test/tmp/app/models/email_verification.rb +58 -0
  42. data/test/tmp/app/models/session.rb +18 -0
  43. data/test/tmp/app/models/user.rb +37 -0
  44. data/test/tmp/config/locales/clave.en.yml +102 -0
  45. data/test/tmp/config/locales/clave.es.yml +102 -0
  46. data/test/tmp/config/routes.rb +10 -1
  47. data/test/tmp/db/migrate/20261014212613_create_accounts.rb +9 -0
  48. data/test/tmp/db/migrate/20261014212614_create_users.rb +17 -0
  49. data/test/tmp/db/migrate/20261014212615_create_sessions.rb +12 -0
  50. data/test/tmp/db/migrate/20261014212616_create_email_verifications.rb +17 -0
  51. data/test/tmp/test/test_helpers/session_test_helper.rb +19 -0
  52. metadata +48 -19
  53. data/test/tmp/.gitignore +0 -5
  54. data/test/tmp/.rubocop.yml +0 -11
  55. data/test/tmp/.standard.yml +0 -1
  56. data/test/tmp/Procfile.dev +0 -3
  57. data/test/tmp/README.md +0 -89
  58. data/test/tmp/app/controllers/home_controller.rb +0 -6
  59. data/test/tmp/app/javascript/application.js +0 -1
  60. data/test/tmp/app/views/home/index.html.erb +0 -204
  61. data/test/tmp/app/views/layouts/application.html.erb +0 -10
  62. data/test/tmp/config/application.rb +0 -15
  63. data/test/tmp/config/database.yml +0 -90
  64. data/test/tmp/config/database.yml.example +0 -90
  65. data/test/tmp/config/environments/development.rb +0 -8
  66. data/test/tmp/config/environments/production.rb +0 -6
  67. data/test/tmp/config/initializers/generators.rb +0 -4
@@ -0,0 +1,109 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Passkey registration and upkeep (maquina:passkeys).
4
+ //
5
+ // The button creates a passkey with the browser's own JSON helpers. On
6
+ // connect the page tells the passkey provider which passkeys this account
7
+ // still has and how it is named, so one removed here disappears from the
8
+ // device's list too. Both signals are skipped where unsupported.
9
+ //
10
+ // The button is rendered visible and only hidden where passkeys are not
11
+ // supported. A Turbo morph puts the server's attributes back without
12
+ // reconnecting this controller, so the view calls refresh on turbo:morph.
13
+ export default class extends Controller {
14
+ static targets = ["button", "error", "unsupported"]
15
+ static values = {
16
+ challengeUrl: String,
17
+ url: String,
18
+ errorMessage: String,
19
+ duplicateMessage: String,
20
+ rpId: String,
21
+ userId: String,
22
+ userName: String,
23
+ displayName: String,
24
+ credentialIds: Array
25
+ }
26
+
27
+ connect() {
28
+ this.refresh()
29
+ if (supported()) this.#signalAccountState()
30
+ }
31
+
32
+ refresh() {
33
+ this.buttonTarget.hidden = !supported()
34
+ this.unsupportedTarget.hidden = supported()
35
+ }
36
+
37
+ async register() {
38
+ this.buttonTarget.disabled = true
39
+ this.#showError(null)
40
+
41
+ try {
42
+ const options = await post(this.challengeUrlValue)
43
+ // Sign in again first: a full load, so the new session gets a fresh page.
44
+ if (options.body.redirect_url) return window.location.assign(options.body.redirect_url)
45
+ if (!options.ok) return this.#showError(options.body.error)
46
+
47
+ let credential
48
+ try {
49
+ credential = await navigator.credentials.create({
50
+ publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(options.body)
51
+ })
52
+ } catch (error) {
53
+ // Cancelled, or this device already holds a passkey for the account.
54
+ if (error.name === "NotAllowedError") return
55
+ if (error.name === "InvalidStateError") return this.#showError(this.duplicateMessageValue)
56
+ return this.#showError()
57
+ }
58
+
59
+ const result = await post(this.urlValue, { credential: credential.toJSON() })
60
+ if (result.body.redirect_url) return visit(result.body.redirect_url)
61
+ this.#showError(result.body.error)
62
+ } finally {
63
+ this.buttonTarget.disabled = false
64
+ }
65
+ }
66
+
67
+ #signalAccountState() {
68
+ const { rpIdValue: rpId, userIdValue: userId } = this
69
+
70
+ PublicKeyCredential.signalAllAcceptedCredentials?.({
71
+ rpId, userId, allAcceptedCredentialIds: this.credentialIdsValue
72
+ }).catch(() => {})
73
+ PublicKeyCredential.signalCurrentUserDetails?.({
74
+ rpId, userId, name: this.userNameValue, displayName: this.displayNameValue
75
+ }).catch(() => {})
76
+ }
77
+
78
+ #showError(message = this.errorMessageValue) {
79
+ this.errorTarget.textContent = message || ""
80
+ this.errorTarget.hidden = !message
81
+ }
82
+ }
83
+
84
+ // JSON POST with the CSRF token; a network failure reads as an empty error.
85
+ async function post(url, body) {
86
+ try {
87
+ const response = await fetch(url, {
88
+ method: "POST",
89
+ headers: {
90
+ "Content-Type": "application/json",
91
+ Accept: "application/json",
92
+ "X-CSRF-Token": document.querySelector("meta[name=csrf-token]")?.content
93
+ },
94
+ body: body && JSON.stringify(body)
95
+ })
96
+ return { ok: response.ok, status: response.status, body: await response.json().catch(() => ({})) }
97
+ } catch {
98
+ return { ok: false, status: 0, body: {} }
99
+ }
100
+ }
101
+
102
+ function visit(url) {
103
+ window.Turbo ? window.Turbo.visit(url) : window.location.assign(url)
104
+ }
105
+
106
+ function supported() {
107
+ return typeof PublicKeyCredential !== "undefined" &&
108
+ typeof PublicKeyCredential.parseCreationOptionsFromJSON === "function"
109
+ }
@@ -0,0 +1,113 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Passkey sign-in (maquina:passkeys).
4
+ //
5
+ // On connect it starts a conditional request: the browser offers saved
6
+ // passkeys in the email field's autofill (autocomplete="username webauthn").
7
+ // The button starts a modal request instead. Both use the browser's own JSON
8
+ // helpers, so no WebAuthn library is needed. Cancelling the prompt is a
9
+ // normal outcome, not an error.
10
+ //
11
+ // The block is rendered visible and only hidden where passkeys are not
12
+ // supported; the view calls refresh on turbo:morph, which puts the server's
13
+ // attributes back without reconnecting this controller. A successful sign-in
14
+ // loads the next page in full, so it starts with the new session's CSRF token
15
+ // and no Turbo snapshot of the signed-out page.
16
+ export default class extends Controller {
17
+ static targets = ["button", "error"]
18
+ static values = { challengeUrl: String, url: String, errorMessage: String }
19
+
20
+ connect() {
21
+ this.refresh()
22
+ if (supported()) this.#autofill()
23
+ }
24
+
25
+ refresh() {
26
+ this.element.hidden = !supported()
27
+ }
28
+
29
+ disconnect() {
30
+ this.#abort()
31
+ }
32
+
33
+ async signIn() {
34
+ this.buttonTarget.disabled = true
35
+ try {
36
+ await this.#authenticate()
37
+ } finally {
38
+ this.buttonTarget.disabled = false
39
+ }
40
+ }
41
+
42
+ async #autofill() {
43
+ if (!(await PublicKeyCredential.isConditionalMediationAvailable?.())) return
44
+
45
+ await this.#authenticate("conditional")
46
+ }
47
+
48
+ async #authenticate(mediation) {
49
+ this.#abort()
50
+ const abortController = this.abortController = new AbortController()
51
+ if (!mediation) this.#showError(null)
52
+
53
+ let credential
54
+ try {
55
+ const options = await post(this.challengeUrlValue)
56
+ if (!options.ok) return this.#showError(options.body.error)
57
+
58
+ credential = await navigator.credentials.get({
59
+ publicKey: PublicKeyCredential.parseRequestOptionsFromJSON(options.body),
60
+ signal: abortController.signal,
61
+ ...(mediation && { mediation })
62
+ })
63
+ } catch (error) {
64
+ if (abortController.signal.aborted) return
65
+ if (error.name !== "NotAllowedError") return this.#showError()
66
+ // The modal prompt was cancelled: offer passkeys in autofill again.
67
+ if (!mediation) this.#autofill()
68
+ return
69
+ }
70
+
71
+ const result = await post(this.urlValue, { credential: credential.toJSON() })
72
+ if (result.body.redirect_url) return window.location.assign(result.body.redirect_url)
73
+
74
+ if (result.status === 404 && result.body.credential_id) {
75
+ // Deleted on the server: ask the passkey provider to drop it too.
76
+ PublicKeyCredential.signalUnknownCredential?.({ rpId: result.body.rp_id, credentialId: result.body.credential_id })
77
+ }
78
+ this.#showError(result.body.error)
79
+ this.#autofill()
80
+ }
81
+
82
+ #abort() {
83
+ this.abortController?.abort()
84
+ }
85
+
86
+ #showError(message = this.errorMessageValue) {
87
+ this.errorTarget.textContent = message || ""
88
+ this.errorTarget.hidden = !message
89
+ }
90
+ }
91
+
92
+ // JSON POST with the CSRF token; a network failure reads as an empty error.
93
+ async function post(url, body) {
94
+ try {
95
+ const response = await fetch(url, {
96
+ method: "POST",
97
+ headers: {
98
+ "Content-Type": "application/json",
99
+ Accept: "application/json",
100
+ "X-CSRF-Token": document.querySelector("meta[name=csrf-token]")?.content
101
+ },
102
+ body: body && JSON.stringify(body)
103
+ })
104
+ return { ok: response.ok, status: response.status, body: await response.json().catch(() => ({})) }
105
+ } catch {
106
+ return { ok: false, status: 0, body: {} }
107
+ }
108
+ }
109
+
110
+ function supported() {
111
+ return typeof PublicKeyCredential !== "undefined" &&
112
+ typeof PublicKeyCredential.parseRequestOptionsFromJSON === "function"
113
+ }
@@ -0,0 +1,36 @@
1
+ # A WebAuthn credential that signs its owner in (generated by maquina:passkeys).
2
+ #
3
+ # Only the public key is stored; the private key never leaves the device or
4
+ # the password manager, so a leaked table cannot be used to sign in.
5
+ class Passkey < ApplicationRecord
6
+ # AAGUIDs of common passkey providers, to give a new passkey a useful
7
+ # default name. Providers that send no AAGUID fall back to "Passkey".
8
+ PROVIDERS = {
9
+ "fbfc3007-154e-4ecc-8c0b-6e020557d7bd" => "iCloud Keychain",
10
+ "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4" => "Google Password Manager",
11
+ "adce0002-35bc-c60a-648b-0b25f1f05503" => "Chrome on Mac",
12
+ "08987058-cadc-4b81-b6e1-30de50dcbe96" => "Windows Hello",
13
+ "bada5566-a7aa-401f-bd96-45619a55120d" => "1Password",
14
+ "d548826e-79b4-db40-a3d8-11116f7e8349" => "Bitwarden"
15
+ }.freeze
16
+
17
+ belongs_to :user
18
+
19
+ validates :credential_id, presence: true, uniqueness: true
20
+ validates :public_key, presence: true
21
+ validates :name, presence: true, length: {maximum: 100}
22
+
23
+ def self.default_name(aaguid)
24
+ PROVIDERS.fetch(aaguid, "Passkey")
25
+ end
26
+
27
+ def record_use!(sign_count)
28
+ update!(sign_count: sign_count, last_used_at: Time.current)
29
+ end
30
+
31
+ # Synced through a provider (iCloud Keychain, Google Password Manager, a
32
+ # password manager) rather than bound to one device.
33
+ def synced?
34
+ backed_up?
35
+ end
36
+ end
@@ -0,0 +1,77 @@
1
+ <div class="flex flex-1 justify-center px-4 py-8">
2
+ <div class="w-full max-w-2xl space-y-6"
3
+ data-controller="passkey-registration"
4
+ data-action="turbo:morph@document->passkey-registration#refresh"
5
+ data-passkey-registration-challenge-url-value="<%= passkey_challenge_path %>"
6
+ data-passkey-registration-url-value="<%= passkeys_path %>"
7
+ data-passkey-registration-error-message-value="<%= t(".error") %>"
8
+ data-passkey-registration-duplicate-message-value="<%= t(".duplicate") %>"
9
+ data-passkey-registration-rp-id-value="<%= WebAuthn.configuration.rp_id %>"
10
+ data-passkey-registration-user-id-value="<%= Current.user.webauthn_id %>"
11
+ data-passkey-registration-user-name-value="<%= Current.user.email_address %>"
12
+ data-passkey-registration-display-name-value="<%= Current.user.name.presence || Current.user.email_address %>"
13
+ data-passkey-registration-credential-ids-value="<%= @passkeys.map(&:credential_id).to_json %>">
14
+ <div class="flex flex-wrap items-start justify-between gap-4">
15
+ <div>
16
+ <h1 class="text-2xl font-bold text-foreground"><%= t(".title") %></h1>
17
+ <p class="mt-1 text-sm text-muted-foreground"><%= t(".description") %></p>
18
+ </div>
19
+
20
+ <button type="button"
21
+ data-action="passkey-registration#register"
22
+ data-passkey-registration-target="button"
23
+ data-component="button"
24
+ data-variant="primary"
25
+ class="px-4 py-2 text-sm font-medium text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors cursor-pointer disabled:opacity-50">
26
+ <%= t(".add") %>
27
+ </button>
28
+ </div>
29
+
30
+ <p data-passkey-registration-target="error" class="text-sm text-destructive" role="alert" hidden></p>
31
+ <p data-passkey-registration-target="unsupported" class="text-sm text-muted-foreground" hidden><%= t(".unsupported") %></p>
32
+
33
+ <% if @passkeys.empty? %>
34
+ <div class="bg-card rounded-xl border border-border p-8 text-center">
35
+ <p class="text-sm text-muted-foreground"><%= t(".empty") %></p>
36
+ </div>
37
+ <% else %>
38
+ <ul class="bg-card rounded-xl border border-border divide-y divide-border">
39
+ <% @passkeys.each do |passkey| %>
40
+ <li id="<%= dom_id(passkey) %>" class="flex flex-wrap items-center gap-4 p-4">
41
+ <%= form_with model: passkey, class: "flex flex-1 items-center gap-2 min-w-0" do |form| %>
42
+ <%= form.text_field :name, required: true, maxlength: 100,
43
+ aria: { label: Passkey.human_attribute_name(:name) },
44
+ data: { component: "input" },
45
+ class: "min-w-0 flex-1 px-3 py-2 text-sm border border-input rounded-lg focus:ring-2 focus:ring-ring focus:border-ring" %>
46
+ <%= form.submit t(".rename"), data: { component: "button", variant: "ghost" },
47
+ class: "px-3 py-2 text-sm font-medium text-foreground hover:bg-accent rounded-lg cursor-pointer" %>
48
+ <% end %>
49
+
50
+ <div class="text-xs text-muted-foreground space-y-1">
51
+ <p>
52
+ <%= t(".created", date: l(passkey.created_at.to_date, format: :long)) %>
53
+ <% if passkey.synced? %>
54
+ · <span data-component="badge" class="font-medium"><%= t(".synced") %></span>
55
+ <% end %>
56
+ </p>
57
+ <p>
58
+ <%= passkey.last_used_at ? t(".last_used", time: time_ago_in_words(passkey.last_used_at)) : t(".never_used") %>
59
+ </p>
60
+ </div>
61
+
62
+ <%= button_to t(".remove"), passkey_path(passkey), method: :delete,
63
+ form: { data: { turbo_confirm: t(".remove_confirm") } },
64
+ data: { component: "button", variant: "destructive" },
65
+ class: "px-3 py-2 text-sm font-medium text-destructive hover:bg-destructive/10 rounded-lg cursor-pointer" %>
66
+ </li>
67
+ <% end %>
68
+ </ul>
69
+
70
+ <% if @passkeys.one? && !@passkeys.first.synced? %>
71
+ <p class="text-sm text-muted-foreground"><%= t(".add_another") %></p>
72
+ <% end %>
73
+ <% end %>
74
+
75
+ <p class="text-sm text-muted-foreground"><%= t(".fallback") %></p>
76
+ </div>
77
+ </div>
@@ -0,0 +1,23 @@
1
+ <div data-controller="passkey-sign-in"
2
+ data-action="turbo:morph@document->passkey-sign-in#refresh"
3
+ data-passkey-sign-in-challenge-url-value="<%= session_passkey_challenge_path %>"
4
+ data-passkey-sign-in-url-value="<%= session_passkey_path %>"
5
+ data-passkey-sign-in-error-message-value="<%= t("sessions.passkey.error") %>"
6
+ class="mt-6 space-y-4">
7
+ <div class="flex items-center gap-3 text-sm text-muted-foreground">
8
+ <span class="h-px flex-1 bg-border"></span>
9
+ <%= t("sessions.passkey.or") %>
10
+ <span class="h-px flex-1 bg-border"></span>
11
+ </div>
12
+
13
+ <button type="button"
14
+ data-action="passkey-sign-in#signIn"
15
+ data-passkey-sign-in-target="button"
16
+ data-component="button"
17
+ data-variant="outline"
18
+ class="w-full px-4 py-3 text-base font-medium text-foreground bg-background border border-input hover:bg-accent rounded-lg transition-colors cursor-pointer disabled:opacity-50">
19
+ <%= t("sessions.passkey.sign_in") %>
20
+ </button>
21
+
22
+ <p data-passkey-sign-in-target="error" class="text-sm text-destructive text-center" role="alert" hidden></p>
23
+ </div>
@@ -0,0 +1,23 @@
1
+ # Passkeys (maquina:passkeys)
2
+ #
3
+ # A passkey is bound to its relying party: rp_id is the domain it belongs to,
4
+ # and the browser only offers it on pages from allowed_origins. rp_id can be
5
+ # a parent of the origins' host (example.com for https://app.example.com),
6
+ # never another domain. Changing rp_id later orphans every saved passkey.
7
+ #
8
+ # Development and test use localhost; production reads WEBAUTHN_RP_ID and
9
+ # WEBAUTHN_ORIGINS (comma-separated), or webauthn: {rp_id:, origins:} in the
10
+ # credentials.
11
+ webauthn = Rails.application.credentials.webauthn || {}
12
+
13
+ WebAuthn.configure do |config|
14
+ config.rp_name = ENV.fetch("WEBAUTHN_RP_NAME") { Rails.application.class.module_parent_name.titleize }
15
+ config.rp_id = ENV["WEBAUTHN_RP_ID"] || webauthn[:rp_id] || "localhost"
16
+ config.allowed_origins = Array(ENV["WEBAUTHN_ORIGINS"]&.split(",") || webauthn[:origins] || "http://localhost:3000")
17
+ end
18
+
19
+ # A production app still on localhost would accept no passkey at all. The
20
+ # asset build runs in production without the real settings, so it is spared.
21
+ if Rails.env.production? && WebAuthn.configuration.rp_id == "localhost" && ENV["SECRET_KEY_BASE_DUMMY"].blank?
22
+ raise "Passkeys: set WEBAUTHN_RP_ID and WEBAUTHN_ORIGINS (see config/initializers/webauthn.rb)"
23
+ end
@@ -0,0 +1,46 @@
1
+ en:
2
+ flash:
3
+ passkeys:
4
+ created: "Passkey added. You can now sign in with it."
5
+ updated: "Passkey renamed."
6
+ destroyed: "Passkey removed."
7
+ failed: "We couldn't sign you in with that passkey. Try again or use your email."
8
+ unknown: "That passkey is no longer linked to an account. Sign in with your email instead."
9
+ challenge_expired: "The request expired. Please try again."
10
+ registration_failed: "We couldn't add that passkey. Please try again."
11
+ reauthenticate: "Sign in again to manage your passkeys."
12
+
13
+ sessions:
14
+ passkey:
15
+ or: "or"
16
+ sign_in: "Sign in with a passkey"
17
+ error: "Something went wrong with the passkey. Please try again."
18
+
19
+ passkeys:
20
+ index:
21
+ title: "Passkeys"
22
+ description: "Sign in with Face ID, a fingerprint or your device PIN instead of an email code."
23
+ add: "Add a passkey"
24
+ empty: "You don't have any passkeys yet."
25
+ rename: "Save"
26
+ remove: "Remove"
27
+ remove_confirm: "Remove this passkey? You won't be able to sign in with it anymore."
28
+ created: "Added %{date}"
29
+ synced: "Synced"
30
+ last_used: "Last used %{time} ago"
31
+ never_used: "Not used yet"
32
+ add_another: "This passkey lives on one device. Add another so you can still sign in if you lose it."
33
+ fallback: "You can always sign in with a code sent to your email."
34
+ unsupported: "This browser doesn't support passkeys."
35
+ error: "Something went wrong with the passkey. Please try again."
36
+ duplicate: "This device already has a passkey for your account."
37
+
38
+ activerecord:
39
+ models:
40
+ passkey:
41
+ one: "Passkey"
42
+ other: "Passkeys"
43
+ attributes:
44
+ passkey:
45
+ name: "Name"
46
+ last_used_at: "Last used"
@@ -0,0 +1,46 @@
1
+ es:
2
+ flash:
3
+ passkeys:
4
+ created: "Llave de acceso agregada. Ya puedes iniciar sesión con ella."
5
+ updated: "Llave de acceso renombrada."
6
+ destroyed: "Llave de acceso eliminada."
7
+ failed: "No pudimos iniciar tu sesión con esa llave de acceso. Inténtalo de nuevo o usa tu correo."
8
+ unknown: "Esa llave de acceso ya no está vinculada a una cuenta. Inicia sesión con tu correo."
9
+ challenge_expired: "La solicitud expiró. Inténtalo de nuevo."
10
+ registration_failed: "No pudimos agregar esa llave de acceso. Inténtalo de nuevo."
11
+ reauthenticate: "Inicia sesión de nuevo para administrar tus llaves de acceso."
12
+
13
+ sessions:
14
+ passkey:
15
+ or: "o"
16
+ sign_in: "Iniciar sesión con llave de acceso"
17
+ error: "Algo salió mal con la llave de acceso. Inténtalo de nuevo."
18
+
19
+ passkeys:
20
+ index:
21
+ title: "Llaves de acceso"
22
+ description: "Inicia sesión con Face ID, tu huella o el PIN de tu dispositivo en lugar de un código por correo."
23
+ add: "Agregar llave de acceso"
24
+ empty: "Todavía no tienes llaves de acceso."
25
+ rename: "Guardar"
26
+ remove: "Eliminar"
27
+ remove_confirm: "¿Eliminar esta llave de acceso? Ya no podrás iniciar sesión con ella."
28
+ created: "Agregada el %{date}"
29
+ synced: "Sincronizada"
30
+ last_used: "Usada hace %{time}"
31
+ never_used: "Sin usar"
32
+ add_another: "Esta llave de acceso vive en un solo dispositivo. Agrega otra para no perder el acceso si lo pierdes."
33
+ fallback: "Siempre puedes iniciar sesión con un código enviado a tu correo."
34
+ unsupported: "Este navegador no es compatible con llaves de acceso."
35
+ error: "Algo salió mal con la llave de acceso. Inténtalo de nuevo."
36
+ duplicate: "Este dispositivo ya tiene una llave de acceso para tu cuenta."
37
+
38
+ activerecord:
39
+ models:
40
+ passkey:
41
+ one: "Llave de acceso"
42
+ other: "Llaves de acceso"
43
+ attributes:
44
+ passkey:
45
+ name: "Nombre"
46
+ last_used_at: "Último uso"
@@ -0,0 +1,6 @@
1
+ class AddWebauthnIdToUsers < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ add_column :users, :webauthn_id, :string
4
+ add_index :users, :webauthn_id, unique: true
5
+ end
6
+ end
@@ -0,0 +1,20 @@
1
+ class CreatePasskeys < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :passkeys do |t|
4
+ t.references :user, null: false, foreign_key: true
5
+ t.string :credential_id, null: false
6
+ t.text :public_key, null: false
7
+ t.bigint :sign_count, null: false, default: 0
8
+ t.string :name, null: false
9
+ t.json :transports
10
+ t.string :aaguid, limit: 36
11
+ t.boolean :backup_eligible, null: false, default: false
12
+ t.boolean :backed_up, null: false, default: false
13
+ t.datetime :last_used_at
14
+
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :passkeys, :credential_id, unique: true
19
+ end
20
+ end
@@ -0,0 +1,99 @@
1
+ require "test_helper"
2
+ require_relative "../test_helpers/session_test_helper"
3
+
4
+ # The passkey flow end to end (generated by maquina:passkeys), with
5
+ # WebAuthn::FakeClient as the browser and the authenticator.
6
+ class PasskeysTest < ActionDispatch::IntegrationTest
7
+ setup do
8
+ @user = User.create!(account: Account.create!(name: "Acme"), email_address: "passkey@example.com")
9
+ end
10
+
11
+ test "adds a passkey and signs in with it" do
12
+ sign_in_as @user
13
+ register_passkey
14
+ assert_response :created
15
+ passkey = @user.passkeys.sole
16
+
17
+ sign_out
18
+ sign_in_with_passkey(@user)
19
+
20
+ assert_response :success
21
+ assert response.parsed_body["redirect_url"].present?
22
+ assert cookies["session_id"].present?
23
+ assert passkey.reload.last_used_at
24
+ end
25
+
26
+ test "a challenge signs in once" do
27
+ sign_in_as @user
28
+ register_passkey
29
+ sign_out
30
+
31
+ post session_passkey_challenge_path, as: :json
32
+ assertion = webauthn_client.get(challenge: response.parsed_body["challenge"], rp_id: WebAuthn.configuration.rp_id,
33
+ user_verified: true, user_handle: Base64.urlsafe_decode64(@user.reload.webauthn_id))
34
+ post session_passkey_path, params: {credential: assertion}, as: :json
35
+ assert_response :success
36
+
37
+ post session_passkey_path, params: {credential: assertion}, as: :json
38
+ assert_response :unprocessable_entity
39
+ end
40
+
41
+ test "a passkey needs user verification" do
42
+ sign_in_as @user
43
+ post passkey_challenge_path, as: :json
44
+ credential = webauthn_client.create(challenge: response.parsed_body["challenge"], rp_id: WebAuthn.configuration.rp_id,
45
+ user_verified: false)
46
+
47
+ post passkeys_path, params: {credential: credential}, as: :json
48
+
49
+ assert_response :unprocessable_entity
50
+ assert_empty @user.passkeys
51
+ end
52
+
53
+ test "a removed passkey no longer signs in" do
54
+ sign_in_as @user
55
+ register_passkey
56
+ @user.passkeys.destroy_all
57
+ sign_out
58
+
59
+ sign_in_with_passkey(@user)
60
+
61
+ assert_response :not_found
62
+ assert response.parsed_body["credential_id"].present?
63
+ end
64
+
65
+ test "a blocked user cannot sign in with a passkey" do
66
+ sign_in_as @user
67
+ register_passkey
68
+ sign_out
69
+ @user.block!
70
+
71
+ sign_in_with_passkey(@user)
72
+
73
+ assert_response :unprocessable_entity
74
+ end
75
+
76
+ test "adding a passkey needs a recent sign-in" do
77
+ sign_in_as @user
78
+ Current.session.update_column(:created_at, 2.hours.ago)
79
+
80
+ post passkey_challenge_path, as: :json
81
+
82
+ assert_response :unauthorized
83
+ assert_equal new_session_url, response.parsed_body["redirect_url"]
84
+ end
85
+
86
+ test "renames and removes a passkey" do
87
+ sign_in_as @user
88
+ register_passkey
89
+ passkey = @user.passkeys.sole
90
+
91
+ patch passkey_path(passkey), params: {passkey: {name: "Work laptop"}}
92
+ assert_redirected_to passkeys_path
93
+ assert_equal "Work laptop", passkey.reload.name
94
+
95
+ delete passkey_path(passkey)
96
+ assert_redirected_to passkeys_path
97
+ assert_empty @user.passkeys.reload
98
+ end
99
+ end
@@ -0,0 +1,38 @@
1
+ # Passkeys in integration tests (generated by maquina:passkeys).
2
+ #
3
+ # WebAuthn::FakeClient plays the browser and the authenticator, so tests go
4
+ # through the real endpoints: register_passkey needs a signed-in user
5
+ # (sign_in_as), sign_in_with_passkey needs a passkey the same client made.
6
+ #
7
+ # sign_in_as users(:one)
8
+ # register_passkey
9
+ # sign_out
10
+ # sign_in_with_passkey(users(:one))
11
+ # assert_response :success
12
+ require "webauthn/fake_client"
13
+
14
+ module WebauthnTestHelper
15
+ def webauthn_client
16
+ @webauthn_client ||= WebAuthn::FakeClient.new(WebAuthn.configuration.allowed_origins.first)
17
+ end
18
+
19
+ def register_passkey(client: webauthn_client)
20
+ post passkey_challenge_path, as: :json
21
+ credential = client.create(challenge: response.parsed_body["challenge"], rp_id: WebAuthn.configuration.rp_id,
22
+ user_verified: true)
23
+
24
+ post passkeys_path, params: {credential: credential}, as: :json
25
+ end
26
+
27
+ def sign_in_with_passkey(user, client: webauthn_client)
28
+ post session_passkey_challenge_path, as: :json
29
+ assertion = client.get(challenge: response.parsed_body["challenge"], rp_id: WebAuthn.configuration.rp_id,
30
+ user_verified: true, user_handle: Base64.urlsafe_decode64(user.reload.webauthn_id))
31
+
32
+ post session_passkey_path, params: {credential: assertion}, as: :json
33
+ end
34
+ end
35
+
36
+ ActiveSupport.on_load(:action_dispatch_integration_test) do
37
+ include WebauthnTestHelper
38
+ end
@@ -1,3 +1,3 @@
1
1
  module MaquinaGenerators
2
- VERSION = "0.7.0"
2
+ VERSION = "0.8.0"
3
3
  end