unmagic-passkeys 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 (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/LICENSE +21 -0
  4. data/NOTICE +9 -0
  5. data/README.md +151 -0
  6. data/app/assets/javascripts/unmagic/passkeys/passkey.js +236 -0
  7. data/app/assets/javascripts/unmagic/passkeys/webauthn.js +83 -0
  8. data/app/controllers/unmagic/passkeys/challenges_controller.rb +49 -0
  9. data/app/models/unmagic/passkeys/credential.rb +103 -0
  10. data/config/importmap.rb +5 -0
  11. data/config/routes.rb +2 -0
  12. data/lib/generators/unmagic/passkeys/install_generator.rb +51 -0
  13. data/lib/generators/unmagic/passkeys/templates/POST_INSTALL +19 -0
  14. data/lib/generators/unmagic/passkeys/templates/create_unmagic_passkeys_credentials.rb.tt +19 -0
  15. data/lib/unmagic/passkeys/engine.rb +78 -0
  16. data/lib/unmagic/passkeys/form_helper.rb +128 -0
  17. data/lib/unmagic/passkeys/holder.rb +143 -0
  18. data/lib/unmagic/passkeys/request.rb +77 -0
  19. data/lib/unmagic/passkeys/version.rb +5 -0
  20. data/lib/unmagic/passkeys/web_authn/authenticator/assertion_response.rb +88 -0
  21. data/lib/unmagic/passkeys/web_authn/authenticator/attestation.rb +73 -0
  22. data/lib/unmagic/passkeys/web_authn/authenticator/attestation_response.rb +71 -0
  23. data/lib/unmagic/passkeys/web_authn/authenticator/attestation_verifiers/none.rb +24 -0
  24. data/lib/unmagic/passkeys/web_authn/authenticator/data.rb +174 -0
  25. data/lib/unmagic/passkeys/web_authn/authenticator/response.rb +141 -0
  26. data/lib/unmagic/passkeys/web_authn/cbor_decoder.rb +269 -0
  27. data/lib/unmagic/passkeys/web_authn/cose_key.rb +183 -0
  28. data/lib/unmagic/passkeys/web_authn/current.rb +19 -0
  29. data/lib/unmagic/passkeys/web_authn/public_key_credential/creation_options.rb +109 -0
  30. data/lib/unmagic/passkeys/web_authn/public_key_credential/options.rb +80 -0
  31. data/lib/unmagic/passkeys/web_authn/public_key_credential/request_options.rb +55 -0
  32. data/lib/unmagic/passkeys/web_authn/public_key_credential.rb +153 -0
  33. data/lib/unmagic/passkeys/web_authn/relying_party.rb +50 -0
  34. data/lib/unmagic/passkeys/web_authn.rb +84 -0
  35. data/lib/unmagic/passkeys.rb +41 -0
  36. metadata +152 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 645c820a4995965db7ac6d5a146b1c26fa058670ee99159e6b69c2fc4d8c09b9
4
+ data.tar.gz: f91753169ab1fad8ef5c789319babb20ee59806b3bc0bb6a56fb43075070091c
5
+ SHA512:
6
+ metadata.gz: 40df919dbc7b4c5e1496bcba443898bb332af4dbe62fe87624f105ce5aa91dd2dd2e9774196a5bebddb5b3713f333343daafd02f928d575dacda5561cef69134
7
+ data.tar.gz: e3be802090ee4736ba752d702972857224440a95b65b1c3654ab628e33d6e5667f5298d8301968ffb8e2f399a3aa7cfba89237d28b9ab44ccd29ab33e73e43dd
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ### Added
6
+ - Initial extraction: passkey (WebAuthn) registration and authentication for Rails
7
+ as a self-contained engine — pure-Ruby CBOR/COSE/attestation/assertion, stateless
8
+ signed challenges, a `has_passkeys` model macro, form helpers, a challenge
9
+ endpoint, and JavaScript web components. No external dependencies.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Keith Pitt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/NOTICE ADDED
@@ -0,0 +1,9 @@
1
+ unmagic-passkeys
2
+
3
+ The WebAuthn ceremony implementation (CBOR decoder, COSE key parsing,
4
+ attestation/assertion handling, the passkey model, holder concern, form helpers
5
+ and JavaScript web components) is derived from the in-progress `ActionPack::Passkey`
6
+ work in the Rails / Basecamp codebases. It has been extracted, renamed under the
7
+ `Unmagic::Passkeys` namespace, and packaged as a standalone Rails engine.
8
+
9
+ Original authors retain credit for the underlying design and cryptography.
data/README.md ADDED
@@ -0,0 +1,151 @@
1
+ # Unmagic::Passkeys
2
+
3
+ Passkey (WebAuthn) authentication for Rails, backed by Active Record, with **no
4
+ external dependencies**. The WebAuthn ceremonies — CBOR decoding, COSE key parsing,
5
+ attestation and assertion verification — are implemented in pure Ruby on top of
6
+ stdlib OpenSSL. Challenges are stateless (signed, expiring tokens), so there's no
7
+ server-side challenge storage.
8
+
9
+ ## Provenance
10
+
11
+ This gem is **extracted from the [`fizzy`](https://github.com/basecamp) codebase**,
12
+ where the WebAuthn/passkey implementation lives vendored under `lib/action_pack/` as
13
+ `ActionPack::Passkey` / `ActionPack::WebAuthn`. By the looks of it (the `ActionPack::`
14
+ namespace, the railtie wiring, the omakase style) it's on its way to becoming a
15
+ first-class **Rails** feature eventually.
16
+
17
+ Until that lands and ships, this is the **extracted, standalone version** — the same
18
+ code lifted out, renamed under `Unmagic::Passkeys`, and packaged as a self-contained
19
+ Rails engine you can drop into an app today. If/when an official Rails passkeys API
20
+ arrives, prefer it; this gem exists to bridge the gap in the meantime.
21
+
22
+ See `NOTICE` for attribution.
23
+
24
+ ## Installation
25
+
26
+ ```ruby
27
+ # Gemfile
28
+ gem "unmagic-passkeys"
29
+ ```
30
+
31
+ ```sh
32
+ bin/rails generate unmagic:passkeys:install # copies the migration, wires the JS
33
+ bin/rails db:migrate
34
+ ```
35
+
36
+ ## Holder model
37
+
38
+ Declare which model owns passkeys with `has_passkeys`. `name` is the account
39
+ identifier shown by the authenticator; `display_name` is a friendly label.
40
+
41
+ ```ruby
42
+ class User < ApplicationRecord
43
+ has_passkeys name: :email_address, display_name: :name
44
+ end
45
+ ```
46
+
47
+ This adds a polymorphic `has_many :passkeys` association and
48
+ `passkey_registration_options` / `passkey_authentication_options`.
49
+
50
+ ## API
51
+
52
+ ```ruby
53
+ # Registration ceremony
54
+ options = Unmagic::Passkeys.registration_options(holder: user) # -> pass to navigator.credentials.create()
55
+ passkey = user.passkeys.register(params[:passkey]) # verifies attestation, persists
56
+
57
+ # Authentication ceremony
58
+ options = Unmagic::Passkeys.authentication_options # -> pass to navigator.credentials.get()
59
+ passkey = Unmagic::Passkeys.authenticate(params[:passkey]) # verified credential, or nil
60
+ ```
61
+
62
+ The engine mounts a stateless challenge endpoint at `POST /unmagic/passkeys/challenge`
63
+ (`passkey_challenge_path`), which the JavaScript refreshes before each ceremony.
64
+
65
+ ## Host wiring
66
+
67
+ The engine ships the primitives; your app owns the login/registration controllers and
68
+ views. The form helpers render self-contained web components — include the JS once:
69
+
70
+ ```js
71
+ // app/javascript/application.js
72
+ import "unmagic/passkeys"
73
+ ```
74
+
75
+ **Sign in** (`app/views/sessions/new.html.erb`):
76
+
77
+ ```erb
78
+ <%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path,
79
+ options: @authentication_options, mediation: "conditional" %>
80
+ ```
81
+
82
+ ```ruby
83
+ class Sessions::PasskeysController < ApplicationController
84
+ include Unmagic::Passkeys::Request
85
+
86
+ def create
87
+ if credential = Unmagic::Passkeys.authenticate(passkey_authentication_params)
88
+ start_new_session_for credential.holder
89
+ redirect_to after_authentication_url
90
+ else
91
+ redirect_to new_session_path, alert: "That passkey didn't work."
92
+ end
93
+ end
94
+ end
95
+ ```
96
+
97
+ **Register** (signed-in):
98
+
99
+ ```erb
100
+ <%= passkey_registration_button "Register a passkey", passkeys_path,
101
+ options: @registration_options %>
102
+ ```
103
+
104
+ ```ruby
105
+ class PasskeysController < ApplicationController
106
+ include Unmagic::Passkeys::Request # sets the WebAuthn request context + param helpers
107
+
108
+ def index
109
+ @registration_options = passkey_registration_options(holder: Current.user)
110
+ end
111
+
112
+ def create
113
+ Current.user.passkeys.register(passkey_registration_params)
114
+ redirect_to passkeys_path, notice: "Passkey added."
115
+ end
116
+ end
117
+ ```
118
+
119
+ `Unmagic::Passkeys::Request` provides `passkey_registration_params`,
120
+ `passkey_authentication_params`, `passkey_registration_options`,
121
+ `passkey_authentication_options`, and sets `Unmagic::Passkeys::WebAuthn::Current`
122
+ (host/origin) per request.
123
+
124
+ ## Configuration
125
+
126
+ ```ruby
127
+ # config/initializers/passkeys.rb
128
+ Rails.application.configure do
129
+ config.unmagic_passkeys.web_authn.default_creation_options = { attestation: :none }
130
+ config.unmagic_passkeys.web_authn.default_request_options = { user_verification: :required }
131
+ config.unmagic_passkeys.web_authn.creation_challenge_expiration = 10.minutes
132
+ config.unmagic_passkeys.web_authn.request_challenge_expiration = 5.minutes
133
+ # config.unmagic_passkeys.parent_class_name = "ApplicationRecord"
134
+ end
135
+ ```
136
+
137
+ ## Development
138
+
139
+ ```sh
140
+ bundle install
141
+ bundle exec rspec # specs (incl. a full register→authenticate round-trip)
142
+ bundle exec rubocop
143
+ ```
144
+
145
+ Supported algorithms: ES256 (P-256), EdDSA (Ed25519), RS256. Only the `none`
146
+ attestation format is verified by default; register others with
147
+ `Unmagic::Passkeys::WebAuthn.register_attestation_verifier`.
148
+
149
+ ## License
150
+
151
+ MIT — see `LICENSE`. Attribution in `NOTICE`.
@@ -0,0 +1,236 @@
1
+ // Web components for the Unmagic::Passkeys Ruby helpers.
2
+ //
3
+ // <unmagic-passkey-registration-button> — wraps a registration ceremony form
4
+ // <unmagic-passkey-sign-in-button> — wraps an authentication ceremony form
5
+ //
6
+ // The Ruby form helpers render the component markup including the inner form,
7
+ // hidden fields, button, and error messages. The components handle the WebAuthn
8
+ // ceremony lifecycle (challenge refresh, credential creation/authentication,
9
+ // form submission) and error state toggling.
10
+ //
11
+ // Custom events (all bubble):
12
+ // passkey:start — ceremony begun
13
+ // passkey:success — credential obtained, form about to submit
14
+ // passkey:error — ceremony failed; detail: { error, cancelled }
15
+ //
16
+ // Attributes (rendered by the Ruby form helpers):
17
+ // options — JSON WebAuthn options (creation or request, on both)
18
+ // challenge-url — endpoint to refresh the challenge nonce (on both)
19
+ // mediation — WebAuthn mediation hint, e.g. "conditional" (on rails-passkey-sign-in-button)
20
+
21
+ import { register, authenticate } from "unmagic/passkeys/webauthn"
22
+
23
+ // Base class for passkey web components. Manages the shared ceremony lifecycle:
24
+ // challenge refresh, button state, error display, and event dispatch.
25
+ // Subclasses implement `perform()` to run the specific WebAuthn ceremony
26
+ // and `fillForm()` to populate hidden fields before submission.
27
+ class PasskeyButton extends HTMLElement {
28
+ connectedCallback() {
29
+ this.button.addEventListener("click", this.#perform)
30
+ }
31
+
32
+ disconnectedCallback() {
33
+ this.abortConditionalMediation?.()
34
+ this.button.removeEventListener("click", this.#perform)
35
+ this.button.disabled = false
36
+ this.#hideErrors()
37
+ }
38
+
39
+ get button() {
40
+ return this.querySelector("[data-passkey]")
41
+ }
42
+
43
+ get form() {
44
+ return this.querySelector("form")
45
+ }
46
+
47
+ get options() {
48
+ return JSON.parse(this.getAttribute("options"))
49
+ }
50
+
51
+ get challengeUrl() {
52
+ return this.getAttribute("challenge-url")
53
+ }
54
+
55
+ // Arrow function to preserve `this` binding for addEventListener/removeEventListener.
56
+ #perform = async () => {
57
+ await this.abortConditionalMediation?.()
58
+ this.button.disabled = true
59
+ this.#hideErrors()
60
+ this.button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
61
+
62
+ try {
63
+ const options = this.options
64
+
65
+ if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser")
66
+ if (!options) throw new Error("Missing passkey options")
67
+
68
+ await refreshChallenge(options, this.challengeUrl, this.purpose)
69
+ const passkey = await this.perform(options)
70
+
71
+ this.button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
72
+ this.fillForm(passkey)
73
+ this.form.submit()
74
+ } catch (error) {
75
+ this.button.disabled = false
76
+ this.#handleError(error)
77
+ }
78
+ }
79
+
80
+ #handleError(error) {
81
+ console.error("Passkey ceremony failed", error)
82
+ const type = errorType(error)
83
+ this.#showError(type)
84
+ this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, type } }))
85
+ }
86
+
87
+ #showError(type) {
88
+ const el = this.querySelector(`[data-passkey-error="${type}"]`)
89
+ if (el) el.hidden = false
90
+ }
91
+
92
+ #hideErrors() {
93
+ for (const el of this.querySelectorAll("[data-passkey-error]")) el.hidden = true
94
+ }
95
+ }
96
+
97
+ class PasskeyRegistrationButton extends PasskeyButton {
98
+ get purpose() { return "registration" }
99
+
100
+ async perform(options) {
101
+ return await register(options)
102
+ }
103
+
104
+ fillForm(passkey) {
105
+ fillRegistrationForm(this.form, passkey)
106
+ }
107
+ }
108
+
109
+ class PasskeySignInButton extends PasskeyButton {
110
+ #conditionalMediationController = null
111
+ #conditionalMediationPromise = null
112
+
113
+ get purpose() { return "authentication" }
114
+
115
+ connectedCallback() {
116
+ super.connectedCallback()
117
+ if (this.mediation === "conditional") this.#attemptConditionalMediation()
118
+ }
119
+
120
+ get mediation() {
121
+ return this.getAttribute("mediation")
122
+ }
123
+
124
+ async perform(options, { signal, mediation } = {}) {
125
+ return await authenticate(options, { signal, mediation })
126
+ }
127
+
128
+ fillForm(passkey) {
129
+ fillSignInForm(this.form, passkey)
130
+ }
131
+
132
+ async abortConditionalMediation() {
133
+ if (this.#conditionalMediationController) {
134
+ this.#conditionalMediationController.abort()
135
+ await this.#conditionalMediationPromise
136
+ }
137
+ }
138
+
139
+ async #attemptConditionalMediation() {
140
+ if (await this.#conditionalMediationAvailable()) {
141
+ const options = this.options
142
+
143
+ this.form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
144
+
145
+ this.#conditionalMediationController = new AbortController()
146
+ this.#conditionalMediationPromise = this.#runConditionalMediation(options)
147
+ }
148
+ }
149
+
150
+ async #runConditionalMediation(options) {
151
+ try {
152
+ await refreshChallenge(options, this.challengeUrl, this.purpose)
153
+ const passkey = await this.perform(options, { signal: this.#conditionalMediationController.signal, mediation: this.mediation })
154
+
155
+ this.form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
156
+ this.fillForm(passkey)
157
+ this.form.submit()
158
+ } catch (error) {
159
+ if (error.name === "AbortError") return
160
+
161
+ console.error("Passkey conditional mediation failed", error)
162
+ const type = errorType(error)
163
+ this.button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, type } }))
164
+ } finally {
165
+ this.#conditionalMediationController = null
166
+ this.#conditionalMediationPromise = null
167
+ }
168
+ }
169
+
170
+ async #conditionalMediationAvailable() {
171
+ return this.options &&
172
+ passkeysAvailable() &&
173
+ await window.PublicKeyCredential.isConditionalMediationAvailable?.()
174
+ }
175
+ }
176
+
177
+ customElements.define("unmagic-passkey-registration-button", PasskeyRegistrationButton)
178
+ customElements.define("unmagic-passkey-sign-in-button", PasskeySignInButton)
179
+
180
+ // -- Shared helpers ----------------------------------------------------------
181
+
182
+ function errorType(error) {
183
+ switch (error.name) {
184
+ case "AbortError":
185
+ case "NotAllowedError": return "cancelled"
186
+ case "InvalidStateError": return "duplicate"
187
+ default: return "error"
188
+ }
189
+ }
190
+
191
+ function passkeysAvailable() {
192
+ return !!window.PublicKeyCredential
193
+ }
194
+
195
+ async function refreshChallenge(options, challengeUrl, purpose) {
196
+ if (!challengeUrl) throw new Error("Missing passkey challenge URL")
197
+ const token = document.querySelector('meta[name="csrf-token"]')?.content
198
+
199
+ const body = new URLSearchParams()
200
+ if (purpose) body.append("purpose", purpose)
201
+
202
+ const response = await fetch(challengeUrl, {
203
+ method: "POST",
204
+ credentials: "same-origin",
205
+ headers: {
206
+ "X-CSRF-Token": token,
207
+ "Accept": "application/json"
208
+ },
209
+ body
210
+ })
211
+
212
+ if (!response.ok) throw new Error("Failed to refresh challenge")
213
+
214
+ const { challenge } = await response.json()
215
+ options.challenge = challenge
216
+ }
217
+
218
+ function fillRegistrationForm(form, passkey) {
219
+ form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json
220
+ form.querySelector('[data-passkey-field="attestation_object"]').value = passkey.attestation_object
221
+
222
+ const template = form.querySelector('[data-passkey-field="transports"]')
223
+ for (const transport of passkey.transports) {
224
+ const input = template.cloneNode()
225
+ input.value = transport
226
+ template.before(input)
227
+ }
228
+ template.remove()
229
+ }
230
+
231
+ function fillSignInForm(form, passkey) {
232
+ form.querySelector('[data-passkey-field="id"]').value = passkey.id
233
+ form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json
234
+ form.querySelector('[data-passkey-field="authenticator_data"]').value = passkey.authenticator_data
235
+ form.querySelector('[data-passkey-field="signature"]').value = passkey.signature
236
+ }
@@ -0,0 +1,83 @@
1
+ // Thin wrapper around the browser WebAuthn API (navigator.credentials).
2
+ //
3
+ // Handles the base64url ↔ ArrayBuffer conversions required by the spec so
4
+ // callers can work with plain JSON objects from the server-rendered meta tags.
5
+
6
+ // Call navigator.credentials.create() with the given creation options.
7
+ // Returns { client_data_json, attestation_object, transports } with all
8
+ // binary fields encoded as base64url strings ready for form submission.
9
+ export async function register(options) {
10
+ const publicKey = prepareCreationOptions(options)
11
+ const credential = await navigator.credentials.create({ publicKey })
12
+
13
+ return {
14
+ client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),
15
+ attestation_object: bufferToBase64url(credential.response.attestationObject),
16
+ transports: credential.response.getTransports?.() || []
17
+ }
18
+ }
19
+
20
+ // Call navigator.credentials.get() with the given request options.
21
+ // Accepts an optional signal (AbortSignal) and mediation hint ("conditional"
22
+ // for autofill UI). Returns { id, client_data_json, authenticator_data, signature }
23
+ // with binary fields encoded as base64url strings.
24
+ export async function authenticate(options, { signal, mediation } = {}) {
25
+ const publicKey = prepareRequestOptions(options)
26
+ const credential = await navigator.credentials.get({ publicKey, signal, mediation })
27
+
28
+ return {
29
+ id: credential.id,
30
+ client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),
31
+ authenticator_data: bufferToBase64url(credential.response.authenticatorData),
32
+ signature: bufferToBase64url(credential.response.signature)
33
+ }
34
+ }
35
+
36
+ // Convert JSON creation options into the format expected by the browser:
37
+ // decode base64url challenge, user.id, and excludeCredentials[].id into ArrayBuffers.
38
+ function prepareCreationOptions(options) {
39
+ return {
40
+ ...options,
41
+ challenge: base64urlToBuffer(options.challenge),
42
+ user: { ...options.user, id: base64urlToBuffer(options.user.id) },
43
+ excludeCredentials: (options.excludeCredentials || []).map(cred => ({
44
+ ...cred,
45
+ id: base64urlToBuffer(cred.id)
46
+ }))
47
+ }
48
+ }
49
+
50
+ // Convert JSON request options into the format expected by the browser:
51
+ // decode base64url challenge and allowCredentials[].id into ArrayBuffers.
52
+ // Strips allowCredentials entirely when empty so the browser prompts for
53
+ // any available credential (required for conditional mediation).
54
+ function prepareRequestOptions(options) {
55
+ const prepared = {
56
+ ...options,
57
+ challenge: base64urlToBuffer(options.challenge)
58
+ }
59
+
60
+ if (options.allowCredentials?.length) {
61
+ prepared.allowCredentials = options.allowCredentials.map(cred => ({
62
+ ...cred,
63
+ id: base64urlToBuffer(cred.id)
64
+ }))
65
+ } else {
66
+ delete prepared.allowCredentials
67
+ }
68
+
69
+ return prepared
70
+ }
71
+
72
+ function base64urlToBuffer(base64url) {
73
+ const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/")
74
+ const padding = "=".repeat((4 - base64.length % 4) % 4)
75
+ const binary = atob(base64 + padding)
76
+ return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer
77
+ }
78
+
79
+ function bufferToBase64url(buffer) {
80
+ const bytes = new Uint8Array(buffer)
81
+ const binary = String.fromCharCode(...bytes)
82
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
83
+ }
@@ -0,0 +1,49 @@
1
+ # = Action Pack Passkey Challenges Controller
2
+ #
3
+ # Generates fresh WebAuthn challenges for passkey ceremonies. The companion
4
+ # JavaScript calls this endpoint before initiating a registration or
5
+ # authentication ceremony so that the challenge is issued just-in-time rather
6
+ # than embedded in the initial page load.
7
+ #
8
+ # The generated challenge is returned in the JSON response body. The challenge
9
+ # is a signed, expiring token that the server can verify on the subsequent
10
+ # form submission without needing server-side state — the challenge is
11
+ # extracted from the authenticator's +clientDataJSON+ response.
12
+ #
13
+ # == Route
14
+ #
15
+ # By default mounted at +/rails/action_pack/passkey/challenge+ (configurable
16
+ # via +config.unmagic_passkeys.routes_prefix+).
17
+ #
18
+ class Unmagic::Passkeys::ChallengesController < ActionController::Base
19
+ include Unmagic::Passkeys::Request
20
+
21
+ # Generates a fresh challenge and returns it as JSON. Accepts an optional
22
+ # +purpose+ parameter ("registration" or "authentication") to select the
23
+ # appropriate challenge expiration. Defaults to "authentication".
24
+ def create
25
+ render json: { challenge: create_passkey_challenge }
26
+ end
27
+
28
+ private
29
+ def create_passkey_challenge
30
+ Unmagic::Passkeys::WebAuthn::PublicKeyCredential::Options.new(
31
+ challenge_expiration: challenge_expiration,
32
+ challenge_purpose: challenge_purpose
33
+ ).challenge
34
+ end
35
+
36
+ def challenge_purpose
37
+ params[:purpose] == "registration" ? "registration" : "authentication"
38
+ end
39
+
40
+ def challenge_expiration
41
+ config = Rails.configuration.unmagic_passkeys.web_authn
42
+
43
+ if challenge_purpose == "registration"
44
+ config.creation_challenge_expiration
45
+ else
46
+ config.request_challenge_expiration
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,103 @@
1
+ # Unmagic::Passkeys::Credential provides WebAuthn passkey registration and authentication backed by Active Record.
2
+ #
3
+ # Passkeys are scoped to a polymorphic +holder+ (typically a User or Identity) and store the
4
+ # credential ID, public key, sign count, and transport hints needed for the WebAuthn ceremonies.
5
+ #
6
+ # == Registration
7
+ #
8
+ # Generate options for the browser's +navigator.credentials.create()+ call, then register the
9
+ # response:
10
+ #
11
+ # options = Unmagic::Passkeys::Credential.registration_options(holder: current_user)
12
+ # # Pass options to the browser
13
+ #
14
+ # passkey = Unmagic::Passkeys::Credential.register(params[:passkey], holder: current_user)
15
+ #
16
+ # == Authentication
17
+ #
18
+ # Generate options for the browser's +navigator.credentials.get()+ call, then authenticate the
19
+ # response:
20
+ #
21
+ # options = Unmagic::Passkeys::Credential.authentication_options
22
+ # # Pass options to the browser
23
+ #
24
+ # passkey = Unmagic::Passkeys::Credential.authenticate(params[:passkey])
25
+ #
26
+ # == Holder integration
27
+ #
28
+ # Call +has_passkeys+ in your model to set up the association and configure ceremony options
29
+ # per-holder. See Unmagic::Passkeys::Holder for details.
30
+ class Unmagic::Passkeys::Credential < Rails.configuration.unmagic_passkeys.parent_class_name.constantize
31
+ self.table_name = "unmagic_passkeys_credentials"
32
+ belongs_to :holder, polymorphic: true
33
+ serialize :transports, coder: JSON, type: Array, default: []
34
+
35
+ class << self
36
+ # Returns a CreationOptions object for the given +holder+, suitable for passing to the
37
+ # browser's +navigator.credentials.create()+ call. Merges global defaults from the Rails
38
+ # configuration, holder-specific options from +holder.passkey_registration_options+, and any
39
+ # additional +options+ overrides.
40
+ def registration_options(holder:, **options)
41
+ Unmagic::Passkeys::WebAuthn::PublicKeyCredential.creation_options(
42
+ **Rails.configuration.unmagic_passkeys.web_authn.default_creation_options.to_h,
43
+ **holder.passkey_registration_options.to_h,
44
+ **options
45
+ )
46
+ end
47
+
48
+ # Verifies the attestation response from the browser and persists a new passkey record.
49
+ # The +passkey+ hash should contain +client_data_json+, +attestation_object+, and +transports+
50
+ # as submitted by the registration form. The challenge is extracted from the authenticator's
51
+ # +clientDataJSON+ response and verified server-side. Any additional +attributes+ (e.g. +holder+)
52
+ # are passed through to +create!+.
53
+ #
54
+ # Raises Unmagic::Passkeys::WebAuthn::InvalidResponseError if the attestation is invalid.
55
+ def register(passkey, **attributes)
56
+ credential = Unmagic::Passkeys::WebAuthn::PublicKeyCredential.register(passkey)
57
+
58
+ create!(**credential.to_h, **attributes)
59
+ end
60
+
61
+ # Returns a RequestOptions object suitable for passing to the browser's
62
+ # +navigator.credentials.get()+ call. When a +holder+ is provided, their existing credentials
63
+ # are included so the browser can offer them for selection. Merges global defaults, holder
64
+ # options, and any additional +options+ overrides.
65
+ def authentication_options(holder: nil, **options)
66
+ Unmagic::Passkeys::WebAuthn::PublicKeyCredential.request_options(
67
+ **Rails.configuration.unmagic_passkeys.web_authn.default_request_options.to_h,
68
+ **holder&.passkey_authentication_options.to_h,
69
+ **options
70
+ )
71
+ end
72
+
73
+ # Looks up a passkey by credential ID and verifies the assertion response from the browser.
74
+ # Returns the authenticated Passkey record, or +nil+ if the credential is not found or
75
+ # verification fails.
76
+ def authenticate(passkey)
77
+ find_by(credential_id: passkey[:id])&.authenticate(passkey)
78
+ end
79
+ end
80
+
81
+ # Verifies the assertion response against this passkey's stored credential and updates the
82
+ # +sign_count+ and +backed_up+ attributes. Returns +self+ on success, or +nil+ if the
83
+ # response is invalid.
84
+ def authenticate(passkey)
85
+ credential = to_public_key_credential
86
+ credential.authenticate(passkey)
87
+ update!(sign_count: credential.sign_count, backed_up: credential.backed_up)
88
+ self
89
+ rescue Unmagic::Passkeys::WebAuthn::InvalidResponseError
90
+ nil
91
+ end
92
+
93
+ # Returns an Unmagic::Passkeys::WebAuthn::PublicKeyCredential initialized from this record's stored
94
+ # credential data.
95
+ def to_public_key_credential
96
+ Unmagic::Passkeys::WebAuthn::PublicKeyCredential.new(
97
+ id: credential_id,
98
+ public_key: public_key,
99
+ sign_count: sign_count,
100
+ transports: transports
101
+ )
102
+ end
103
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Pins merged into the host application's importmap by the engine.
4
+ pin "unmagic/passkeys", to: "unmagic/passkeys/passkey.js"
5
+ pin "unmagic/passkeys/webauthn", to: "unmagic/passkeys/webauthn.js"
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ Unmagic::Passkeys::Engine.routes.draw do
2
+ end