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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +21 -0
- data/NOTICE +9 -0
- data/README.md +151 -0
- data/app/assets/javascripts/unmagic/passkeys/passkey.js +236 -0
- data/app/assets/javascripts/unmagic/passkeys/webauthn.js +83 -0
- data/app/controllers/unmagic/passkeys/challenges_controller.rb +49 -0
- data/app/models/unmagic/passkeys/credential.rb +103 -0
- data/config/importmap.rb +5 -0
- data/config/routes.rb +2 -0
- data/lib/generators/unmagic/passkeys/install_generator.rb +51 -0
- data/lib/generators/unmagic/passkeys/templates/POST_INSTALL +19 -0
- data/lib/generators/unmagic/passkeys/templates/create_unmagic_passkeys_credentials.rb.tt +19 -0
- data/lib/unmagic/passkeys/engine.rb +78 -0
- data/lib/unmagic/passkeys/form_helper.rb +128 -0
- data/lib/unmagic/passkeys/holder.rb +143 -0
- data/lib/unmagic/passkeys/request.rb +77 -0
- data/lib/unmagic/passkeys/version.rb +5 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/assertion_response.rb +88 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/attestation.rb +73 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/attestation_response.rb +71 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/attestation_verifiers/none.rb +24 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/data.rb +174 -0
- data/lib/unmagic/passkeys/web_authn/authenticator/response.rb +141 -0
- data/lib/unmagic/passkeys/web_authn/cbor_decoder.rb +269 -0
- data/lib/unmagic/passkeys/web_authn/cose_key.rb +183 -0
- data/lib/unmagic/passkeys/web_authn/current.rb +19 -0
- data/lib/unmagic/passkeys/web_authn/public_key_credential/creation_options.rb +109 -0
- data/lib/unmagic/passkeys/web_authn/public_key_credential/options.rb +80 -0
- data/lib/unmagic/passkeys/web_authn/public_key_credential/request_options.rb +55 -0
- data/lib/unmagic/passkeys/web_authn/public_key_credential.rb +153 -0
- data/lib/unmagic/passkeys/web_authn/relying_party.rb +50 -0
- data/lib/unmagic/passkeys/web_authn.rb +84 -0
- data/lib/unmagic/passkeys.rb +41 -0
- 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
|
data/config/importmap.rb
ADDED
data/config/routes.rb
ADDED