passkeyed 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/LICENSE.txt +21 -0
- data/README.md +271 -0
- data/lib/generators/passkeyed/install/USAGE +14 -0
- data/lib/generators/passkeyed/install/install_generator.rb +105 -0
- data/lib/generators/passkeyed/install/templates/create_passkeyed_credentials.rb.erb +56 -0
- data/lib/generators/passkeyed/install/templates/initializer.rb +30 -0
- data/lib/generators/passkeyed/install/templates/passkey_controller.js +246 -0
- data/lib/passkeyed/ceremonies.rb +255 -0
- data/lib/passkeyed/configuration.rb +188 -0
- data/lib/passkeyed/credential.rb +58 -0
- data/lib/passkeyed/errors.rb +34 -0
- data/lib/passkeyed/model.rb +98 -0
- data/lib/passkeyed/version.rb +5 -0
- data/lib/passkeyed.rb +19 -0
- data/sig/passkeyed/ceremonies.rbs +22 -0
- data/sig/passkeyed/configuration.rbs +31 -0
- data/sig/passkeyed/credential.rbs +9 -0
- data/sig/passkeyed/errors.rbs +22 -0
- data/sig/passkeyed/model.rbs +16 -0
- data/sig/passkeyed/version.rbs +3 -0
- metadata +208 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Drives the two WebAuthn ceremonies from the browser. The endpoints are
|
|
4
|
+
// supplied as Stimulus values, so this controller stays app-agnostic:
|
|
5
|
+
//
|
|
6
|
+
// <div data-controller="passkey"
|
|
7
|
+
// data-passkey-registration-options-url-value="/passkeys/registration_options"
|
|
8
|
+
// data-passkey-registration-url-value="/passkeys"
|
|
9
|
+
// data-passkey-redirect-url-value="/dashboard">
|
|
10
|
+
// <input data-passkey-target="nickname">
|
|
11
|
+
// <button data-action="passkey#register">Add a passkey</button>
|
|
12
|
+
// <p data-passkey-target="error" role="alert"></p>
|
|
13
|
+
// </div>
|
|
14
|
+
//
|
|
15
|
+
// Serialisation uses the browser's own PublicKeyCredential JSON methods
|
|
16
|
+
// (parseCreationOptionsFromJSON / parseRequestOptionsFromJSON / toJSON), which
|
|
17
|
+
// handle the base64url <-> ArrayBuffer conversion natively — no JavaScript
|
|
18
|
+
// dependency needed.
|
|
19
|
+
//
|
|
20
|
+
// Set data-passkey-conditional-value="true" on a sign-in form to also offer
|
|
21
|
+
// passkeys through the browser's autofill (conditional UI). Pair it with an
|
|
22
|
+
// <input autocomplete="username webauthn"> inside the same element.
|
|
23
|
+
//
|
|
24
|
+
// After a successful ceremony the controller dispatches a cancelable
|
|
25
|
+
// "passkey:registered" / "passkey:authenticated" event (detail: the server's
|
|
26
|
+
// JSON response) and then follows data-passkey-redirect-url-value (or reloads).
|
|
27
|
+
// Call event.preventDefault() in a listener to handle the outcome yourself.
|
|
28
|
+
export default class extends Controller {
|
|
29
|
+
static values = {
|
|
30
|
+
registrationOptionsUrl: String,
|
|
31
|
+
registrationUrl: String,
|
|
32
|
+
authenticationOptionsUrl: String,
|
|
33
|
+
authenticationUrl: String,
|
|
34
|
+
redirectUrl: String,
|
|
35
|
+
conditional: Boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static targets = ["error", "nickname"]
|
|
39
|
+
|
|
40
|
+
// Re-entrancy guard: a second ceremony would rotate the server's challenge
|
|
41
|
+
// and make the first fail confusingly.
|
|
42
|
+
#busy = false
|
|
43
|
+
|
|
44
|
+
// Aborts the pending conditional (autofill) request: browsers allow only one
|
|
45
|
+
// in-flight WebAuthn request, so a modal ceremony must cancel it first.
|
|
46
|
+
#conditionalAbort = null
|
|
47
|
+
|
|
48
|
+
connect() {
|
|
49
|
+
if (this.conditionalValue) this.#startConditional()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
disconnect() {
|
|
53
|
+
this.#abortConditional()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async register(event) {
|
|
57
|
+
event.preventDefault()
|
|
58
|
+
await this.#runCeremony(event, async () => {
|
|
59
|
+
const optionsJSON = await this.#postJson(this.registrationOptionsUrlValue)
|
|
60
|
+
const options = window.PublicKeyCredential.parseCreationOptionsFromJSON(optionsJSON)
|
|
61
|
+
// Prompts for Touch ID / Windows Hello / a PIN and mints the key pair
|
|
62
|
+
// inside the device's secure hardware.
|
|
63
|
+
const credential = await navigator.credentials.create({ publicKey: options })
|
|
64
|
+
|
|
65
|
+
const body = { credential: credential.toJSON() }
|
|
66
|
+
if (this.hasNicknameTarget && this.nicknameTarget.value) {
|
|
67
|
+
body.nickname = this.nicknameTarget.value
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const response = await this.#postJson(this.registrationUrlValue, body)
|
|
71
|
+
this.#finish("registered", response)
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async authenticate(event) {
|
|
76
|
+
event.preventDefault()
|
|
77
|
+
await this.#runCeremony(event, async () => {
|
|
78
|
+
const response = await this.#assert()
|
|
79
|
+
this.#finish("authenticated", response)
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The assertion ceremony shared by the modal button and the autofill path:
|
|
84
|
+
// fetch options, let the authenticator sign, post the assertion back.
|
|
85
|
+
async #assert(mediationOptions = {}) {
|
|
86
|
+
const optionsJSON = await this.#postJson(this.authenticationOptionsUrlValue)
|
|
87
|
+
const options = window.PublicKeyCredential.parseRequestOptionsFromJSON(optionsJSON)
|
|
88
|
+
// The browser shows the account picker and asks for the user's gesture.
|
|
89
|
+
const credential = await navigator.credentials.get({
|
|
90
|
+
publicKey: options,
|
|
91
|
+
...mediationOptions
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
return this.#postJson(this.authenticationUrlValue, { credential: credential.toJSON() })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Conditional UI: park an assertion request behind the browser's autofill,
|
|
98
|
+
// so a <input autocomplete="username webauthn"> offers stored passkeys. The
|
|
99
|
+
// request hangs until the user picks one (or it is aborted), so it must not
|
|
100
|
+
// take the busy lock that guards the modal ceremonies.
|
|
101
|
+
async #startConditional() {
|
|
102
|
+
if (!this.#webAuthnAvailable()) return
|
|
103
|
+
if (!window.PublicKeyCredential.isConditionalMediationAvailable) return
|
|
104
|
+
if (!(await window.PublicKeyCredential.isConditionalMediationAvailable())) return
|
|
105
|
+
if (this.#conditionalAbort) return
|
|
106
|
+
|
|
107
|
+
this.#conditionalAbort = new AbortController()
|
|
108
|
+
let restart = false
|
|
109
|
+
try {
|
|
110
|
+
const response = await this.#assert({
|
|
111
|
+
mediation: "conditional",
|
|
112
|
+
signal: this.#conditionalAbort.signal
|
|
113
|
+
})
|
|
114
|
+
this.#finish("authenticated", response)
|
|
115
|
+
} catch (error) {
|
|
116
|
+
this.#handleError(error)
|
|
117
|
+
// Re-arm autofill unless the abort was deliberate (a modal ceremony took
|
|
118
|
+
// over, or the element disconnected). The user dismissing the sheet, or
|
|
119
|
+
// the server rejecting the assertion (say, an expired challenge in an
|
|
120
|
+
// idle tab), would otherwise leave passkey autofill dead until a reload.
|
|
121
|
+
restart = error.name !== "AbortError"
|
|
122
|
+
} finally {
|
|
123
|
+
this.#conditionalAbort = null
|
|
124
|
+
}
|
|
125
|
+
if (restart) this.#startConditional()
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
#abortConditional() {
|
|
129
|
+
if (this.#conditionalAbort) {
|
|
130
|
+
this.#conditionalAbort.abort()
|
|
131
|
+
this.#conditionalAbort = null
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Shared ceremony scaffolding: feature-detect, guard against double-submit,
|
|
136
|
+
// disable the button for the duration, then run the body. On failure the
|
|
137
|
+
// conditional request is restarted so autofill keeps working after a
|
|
138
|
+
// cancelled modal prompt.
|
|
139
|
+
async #runCeremony(event, body) {
|
|
140
|
+
if (this.#busy) return
|
|
141
|
+
this.#clearError()
|
|
142
|
+
|
|
143
|
+
if (!this.#webAuthnAvailable()) {
|
|
144
|
+
this.#showMessage("Passkeys aren't available in this browser.")
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.#abortConditional()
|
|
149
|
+
|
|
150
|
+
const button = event.currentTarget
|
|
151
|
+
this.#busy = true
|
|
152
|
+
if (button) button.disabled = true
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
await body()
|
|
156
|
+
} catch (error) {
|
|
157
|
+
this.#handleError(error)
|
|
158
|
+
if (this.conditionalValue) this.#startConditional()
|
|
159
|
+
} finally {
|
|
160
|
+
this.#busy = false
|
|
161
|
+
if (button) button.disabled = false
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async #postJson(url, body) {
|
|
166
|
+
const headers = {
|
|
167
|
+
"Content-Type": "application/json",
|
|
168
|
+
Accept: "application/json"
|
|
169
|
+
}
|
|
170
|
+
// Only send the header when the token exists; otherwise fetch coerces
|
|
171
|
+
// `undefined` to the literal string "undefined" and the POST 422s.
|
|
172
|
+
const csrfToken = this.#csrfToken()
|
|
173
|
+
if (csrfToken) headers["X-CSRF-Token"] = csrfToken
|
|
174
|
+
|
|
175
|
+
const response = await fetch(url, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
credentials: "same-origin",
|
|
178
|
+
headers,
|
|
179
|
+
body: body ? JSON.stringify(body) : "{}"
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
if (!response.ok) {
|
|
183
|
+
const data = await response.json().catch(() => ({}))
|
|
184
|
+
throw new Error(data.error || `Request failed (${response.status})`)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return response.json()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Requires the native JSON serialisation methods alongside WebAuthn itself;
|
|
191
|
+
// both have shipped in every major browser (see MDN: PublicKeyCredential).
|
|
192
|
+
#webAuthnAvailable() {
|
|
193
|
+
return Boolean(
|
|
194
|
+
window.PublicKeyCredential &&
|
|
195
|
+
window.isSecureContext &&
|
|
196
|
+
window.PublicKeyCredential.parseCreationOptionsFromJSON &&
|
|
197
|
+
window.PublicKeyCredential.parseRequestOptionsFromJSON
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#csrfToken() {
|
|
202
|
+
return document.querySelector("meta[name='csrf-token']")?.content
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#handleError(error) {
|
|
206
|
+
// Cancelling the OS prompt or letting it time out rejects with
|
|
207
|
+
// NotAllowedError (and AbortError for an aborted request) — a normal
|
|
208
|
+
// outcome, not something to show as a failure.
|
|
209
|
+
if (error.name === "NotAllowedError" || error.name === "AbortError") return
|
|
210
|
+
|
|
211
|
+
this.#showError(error)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Announce the outcome, then redirect unless a listener claimed it.
|
|
215
|
+
#finish(eventName, detail) {
|
|
216
|
+
const event = this.dispatch(eventName, { detail, cancelable: true })
|
|
217
|
+
if (event.defaultPrevented) return
|
|
218
|
+
|
|
219
|
+
this.#redirect()
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
#redirect() {
|
|
223
|
+
if (this.hasRedirectUrlValue) {
|
|
224
|
+
window.location.assign(this.redirectUrlValue)
|
|
225
|
+
} else {
|
|
226
|
+
window.location.reload()
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#showError(error) {
|
|
231
|
+
this.dispatch("error", { detail: { error } })
|
|
232
|
+
this.#showMessage(error.message)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
#showMessage(message) {
|
|
236
|
+
if (this.hasErrorTarget) {
|
|
237
|
+
this.errorTarget.textContent = message
|
|
238
|
+
} else {
|
|
239
|
+
console.error(message)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#clearError() {
|
|
244
|
+
if (this.hasErrorTarget) this.errorTarget.textContent = ""
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Passkeyed
|
|
4
|
+
# Controller-side helpers for the two WebAuthn ceremonies. Include in a
|
|
5
|
+
# controller (it relies on +session+):
|
|
6
|
+
#
|
|
7
|
+
# class SessionsController < ApplicationController
|
|
8
|
+
# include Passkeyed::Ceremonies
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# Each ceremony is the same shape: issue a challenge, let the authenticator
|
|
12
|
+
# sign it, verify the signature. The +*_options+ methods stash the challenge
|
|
13
|
+
# in the session; the bang methods consume it and verify.
|
|
14
|
+
#
|
|
15
|
+
# Both bang methods emit an ActiveSupport::Notifications event
|
|
16
|
+
# ("register.passkeyed" / "authenticate.passkeyed") whose payload carries
|
|
17
|
+
# +:credential+ and +:user+ on success and the standard +:exception+ keys on
|
|
18
|
+
# failure — subscribe to observe failed sign-ins, which the success-only
|
|
19
|
+
# after_* hooks never see.
|
|
20
|
+
module Ceremonies
|
|
21
|
+
# Build creation options for a passwordless, discoverable credential and
|
|
22
|
+
# remember the challenge. Render the return value as JSON to the browser.
|
|
23
|
+
def passkey_registration_options(user)
|
|
24
|
+
# Resolve (and, for a pre-existing user, lazily assign) the WebAuthn user
|
|
25
|
+
# handle before building options, so user.id is never nil in the JSON.
|
|
26
|
+
user_handle = user.passkeyed_webauthn_id!
|
|
27
|
+
|
|
28
|
+
options = WebAuthn::Credential.options_for_create(
|
|
29
|
+
user: {
|
|
30
|
+
id: user_handle,
|
|
31
|
+
name: user.passkey_name,
|
|
32
|
+
display_name: user.passkey_display_name
|
|
33
|
+
},
|
|
34
|
+
exclude: user.passkeyed_credentials.pluck(:external_id),
|
|
35
|
+
authenticator_selection: {
|
|
36
|
+
resident_key: "required",
|
|
37
|
+
user_verification: Passkeyed.configuration.user_verification
|
|
38
|
+
},
|
|
39
|
+
relying_party: Passkeyed.configuration.relying_party
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
stash_challenge(Passkeyed.configuration.registration_challenge_key, options.challenge)
|
|
43
|
+
options
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Verify a registration response against the stored challenge and persist
|
|
47
|
+
# the new credential. Returns the Passkeyed::Credential, or raises
|
|
48
|
+
# Passkeyed::RegistrationError / ChallengeMissing / ChallengeExpired.
|
|
49
|
+
def passkey_register!(user, credential, nickname: nil)
|
|
50
|
+
record = ActiveSupport::Notifications.instrument("register.passkeyed", user: user) do |payload|
|
|
51
|
+
verify_registration(user, credential, nickname: nickname).tap do |created|
|
|
52
|
+
payload[:credential] = created
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Outside the instrumented block and the ceremony's rescues, so a raising
|
|
57
|
+
# hook surfaces as itself and doesn't mark the ceremony event as failed.
|
|
58
|
+
after_passkey_registration(record)
|
|
59
|
+
record
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Build request options for a passwordless sign-in. No allow-list is sent,
|
|
63
|
+
# so the authenticator offers whatever discoverable passkeys it holds for
|
|
64
|
+
# this site. Remembers the challenge; render the return value as JSON.
|
|
65
|
+
def passkey_authentication_options
|
|
66
|
+
options = WebAuthn::Credential.options_for_get(
|
|
67
|
+
user_verification: Passkeyed.configuration.user_verification,
|
|
68
|
+
relying_party: Passkeyed.configuration.relying_party
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
stash_challenge(Passkeyed.configuration.authentication_challenge_key, options.challenge)
|
|
72
|
+
options
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Verify an authentication response. Resolves the credential by its id,
|
|
76
|
+
# checks the signature against the stored public key and challenge, enforces
|
|
77
|
+
# user verification when configured, bumps the signature counter, and
|
|
78
|
+
# returns the owning record. Raises Passkeyed::AuthenticationError /
|
|
79
|
+
# CredentialNotFound / ChallengeMissing / ChallengeExpired.
|
|
80
|
+
def passkey_authenticate!(credential)
|
|
81
|
+
stored = ActiveSupport::Notifications.instrument("authenticate.passkeyed", {}) do |payload|
|
|
82
|
+
verify_authentication(credential).tap do |verified|
|
|
83
|
+
payload[:credential] = verified
|
|
84
|
+
payload[:user] = verified.user
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
after_passkey_authentication(stored, stored.user)
|
|
89
|
+
stored.user
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# The registration ceremony proper: consume the challenge, verify the
|
|
95
|
+
# attestation, persist. Kept apart from passkey_register! so its rescues
|
|
96
|
+
# never rewrite an exception raised by the after_* hook.
|
|
97
|
+
def verify_registration(user, credential, nickname: nil)
|
|
98
|
+
challenge = consume_challenge(Passkeyed.configuration.registration_challenge_key, "registration")
|
|
99
|
+
|
|
100
|
+
webauthn_credential = parse_credential(:from_create, credential, Passkeyed::RegistrationError)
|
|
101
|
+
|
|
102
|
+
# user_verification: true makes webauthn-ruby check the User-Verified flag
|
|
103
|
+
# server-side, which is what actually enforces the configured policy. The
|
|
104
|
+
# value sent to the client is only advisory.
|
|
105
|
+
webauthn_credential.verify(challenge, user_verification: passkeyed_user_verification?)
|
|
106
|
+
|
|
107
|
+
user.passkeyed_credentials.create!(
|
|
108
|
+
external_id: webauthn_credential.id,
|
|
109
|
+
public_key: webauthn_credential.public_key,
|
|
110
|
+
sign_count: webauthn_credential.sign_count,
|
|
111
|
+
nickname: nickname.presence,
|
|
112
|
+
**credential_metadata(webauthn_credential)
|
|
113
|
+
)
|
|
114
|
+
rescue WebAuthn::Error => e
|
|
115
|
+
raise Passkeyed::RegistrationError, e.message
|
|
116
|
+
rescue ActiveRecord::RecordNotUnique
|
|
117
|
+
# A check-then-insert race past the uniqueness validation would otherwise
|
|
118
|
+
# escape as a 500. Surface it as a typed "already registered" error.
|
|
119
|
+
raise Passkeyed::RegistrationError, "Credential already registered"
|
|
120
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
121
|
+
# A credential id already on record (a double-submit, or a credential held
|
|
122
|
+
# by another account) is the common validation failure; report it as
|
|
123
|
+
# "already registered". Any other invalidity — e.g. an over-long nickname —
|
|
124
|
+
# surfaces its own message rather than being mislabeled.
|
|
125
|
+
raise Passkeyed::RegistrationError, "Credential already registered" if
|
|
126
|
+
e.record.errors.of_kind?(:external_id, :taken)
|
|
127
|
+
|
|
128
|
+
raise Passkeyed::RegistrationError, e.record.errors.full_messages.to_sentence
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The authentication ceremony proper; see verify_registration for why it is
|
|
132
|
+
# separate from passkey_authenticate!.
|
|
133
|
+
def verify_authentication(credential)
|
|
134
|
+
challenge = consume_challenge(Passkeyed.configuration.authentication_challenge_key, "authentication")
|
|
135
|
+
|
|
136
|
+
webauthn_credential = parse_credential(:from_get, credential, Passkeyed::AuthenticationError)
|
|
137
|
+
|
|
138
|
+
stored = Passkeyed::Credential.find_by(external_id: webauthn_credential.id)
|
|
139
|
+
# CredentialNotFound is a subclass of AuthenticationError, so a single
|
|
140
|
+
# `rescue Passkeyed::AuthenticationError` collapses both outcomes; do that
|
|
141
|
+
# in your controller and return one generic message to avoid leaking
|
|
142
|
+
# whether a credential id is on record.
|
|
143
|
+
raise Passkeyed::CredentialNotFound, "Unknown credential" unless stored
|
|
144
|
+
|
|
145
|
+
# The owner association is polymorphic, so there is no foreign key: a
|
|
146
|
+
# credential can outlive its owner when rows are removed without callbacks
|
|
147
|
+
# (delete/delete_all, raw SQL). Same error and message as an unknown id,
|
|
148
|
+
# so nothing is leaked about the orphan.
|
|
149
|
+
owner = stored.user
|
|
150
|
+
raise Passkeyed::CredentialNotFound, "Unknown credential" if owner.nil?
|
|
151
|
+
|
|
152
|
+
# Defense in depth: a discoverable assertion carries the user handle the
|
|
153
|
+
# authenticator stored. When present, it must match the credential's owner;
|
|
154
|
+
# a mismatch means the credential/owner mapping doesn't line up.
|
|
155
|
+
asserted_handle = webauthn_credential.user_handle
|
|
156
|
+
if asserted_handle.present? && owner.webauthn_id != asserted_handle
|
|
157
|
+
raise Passkeyed::AuthenticationError, "Credential does not match its owner"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
webauthn_credential.verify(
|
|
161
|
+
challenge,
|
|
162
|
+
public_key: stored.public_key,
|
|
163
|
+
sign_count: stored.sign_count,
|
|
164
|
+
user_verification: passkeyed_user_verification?
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
stored.record_sign_in!(webauthn_credential.sign_count, backed_up: webauthn_credential.backed_up?)
|
|
168
|
+
stored
|
|
169
|
+
rescue WebAuthn::Error => e
|
|
170
|
+
raise Passkeyed::AuthenticationError, e.message
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Overridable hooks, called after a ceremony succeeds. Defaults are no-ops;
|
|
174
|
+
# override in your controller to audit-log, send a "new device" email, bump a
|
|
175
|
+
# "last used" timestamp, etc. They run with full controller context (request,
|
|
176
|
+
# current_user) and outside the ceremony's error handling, so an exception
|
|
177
|
+
# raised here propagates unchanged rather than becoming a Passkeyed::Error.
|
|
178
|
+
def after_passkey_registration(credential); end
|
|
179
|
+
|
|
180
|
+
def after_passkey_authentication(credential, user); end
|
|
181
|
+
|
|
182
|
+
# Where challenges are stashed. Defaults to the controller's session;
|
|
183
|
+
# overridable (and easy to stub in tests).
|
|
184
|
+
def passkeyed_session
|
|
185
|
+
session
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Challenges are stored with their issue time (string keys survive the
|
|
189
|
+
# cookie session's JSON round-trip) so consumption can enforce a lifetime.
|
|
190
|
+
def stash_challenge(key, challenge)
|
|
191
|
+
passkeyed_session[key] = { "challenge" => challenge, "issued_at" => Time.now.to_i }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Read-and-delete the stashed challenge: single-use by construction. Raises
|
|
195
|
+
# ChallengeMissing when absent (or not in the expected hash format) and
|
|
196
|
+
# ChallengeExpired when older than the configured challenge_timeout.
|
|
197
|
+
def consume_challenge(key, ceremony)
|
|
198
|
+
stash = passkeyed_session.delete(key)
|
|
199
|
+
challenge = stash["challenge"] if stash.is_a?(Hash)
|
|
200
|
+
raise Passkeyed::ChallengeMissing, "No #{ceremony} challenge in session" if challenge.blank?
|
|
201
|
+
|
|
202
|
+
age = Time.now.to_i - stash["issued_at"].to_i
|
|
203
|
+
if age > Passkeyed.configuration.challenge_timeout
|
|
204
|
+
raise Passkeyed::ChallengeExpired, "The #{ceremony} challenge has expired; restart the ceremony"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
challenge
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Authenticator metadata worth keeping for a credential-management UI:
|
|
211
|
+
# whether the passkey is synced (backed up) or device-bound, how the
|
|
212
|
+
# authenticator talks to clients, and which authenticator model minted it.
|
|
213
|
+
# Each attribute is guarded on its column so apps that trimmed the optional
|
|
214
|
+
# columns from the generated migration keep registering.
|
|
215
|
+
def credential_metadata(webauthn_credential)
|
|
216
|
+
columns = Passkeyed::Credential.column_names
|
|
217
|
+
metadata = {}
|
|
218
|
+
metadata[:backup_eligible] = webauthn_credential.backup_eligible? if columns.include?("backup_eligible")
|
|
219
|
+
metadata[:backed_up] = webauthn_credential.backed_up? if columns.include?("backed_up")
|
|
220
|
+
metadata[:transports] = webauthn_credential.response.transports.presence if columns.include?("transports")
|
|
221
|
+
metadata[:aaguid] = webauthn_credential.response.aaguid if columns.include?("aaguid")
|
|
222
|
+
metadata
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# True when the configured policy demands the User-Verified flag. webauthn-ruby
|
|
226
|
+
# treats a truthy value as "enforce UV".
|
|
227
|
+
def passkeyed_user_verification?
|
|
228
|
+
Passkeyed.configuration.user_verification == "required"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Parse a browser credential into a webauthn-ruby object, turning malformed
|
|
232
|
+
# input into a clean Passkeyed error instead of a stray NoMethodError that
|
|
233
|
+
# would surface as a 500. Accepts ActionController::Parameters or a Hash.
|
|
234
|
+
# The parsed credential carries the relying party its later +verify+ checks
|
|
235
|
+
# against, so passkeyed's settings apply without touching webauthn-ruby's
|
|
236
|
+
# global configuration.
|
|
237
|
+
def parse_credential(method, credential, error_class)
|
|
238
|
+
WebAuthn::Credential.public_send(
|
|
239
|
+
method,
|
|
240
|
+
normalize_credential(credential),
|
|
241
|
+
relying_party: Passkeyed.configuration.relying_party
|
|
242
|
+
)
|
|
243
|
+
rescue WebAuthn::Error
|
|
244
|
+
raise
|
|
245
|
+
rescue StandardError
|
|
246
|
+
raise error_class, "Malformed credential"
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Accept ActionController::Parameters (what `params[:credential]` is) as
|
|
250
|
+
# well as a plain Hash, so callers need not convert by hand.
|
|
251
|
+
def normalize_credential(credential)
|
|
252
|
+
credential.respond_to?(:to_unsafe_h) ? credential.to_unsafe_h : credential
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Passkeyed
|
|
4
|
+
# Holds passkeyed's settings. The relevant ones are handed to webauthn-ruby
|
|
5
|
+
# as an isolated WebAuthn::RelyingParty (see +relying_party+), never written
|
|
6
|
+
# to WebAuthn's global configuration.
|
|
7
|
+
#
|
|
8
|
+
# Passkeyed.configure do |config|
|
|
9
|
+
# config.rp_name = "Acme"
|
|
10
|
+
# config.rp_id = "acme.example" # optional
|
|
11
|
+
# config.allowed_origins = ["https://acme.example"] # required when deployed
|
|
12
|
+
# config.user_verification = "required"
|
|
13
|
+
# end
|
|
14
|
+
class Configuration
|
|
15
|
+
# The WebAuthn userVerification enum. We normalize to these exact strings so
|
|
16
|
+
# the value advertised to the client and the value enforced server-side can
|
|
17
|
+
# never diverge (a symbol or mis-cased string would silently disable
|
|
18
|
+
# enforcement while still advertising "required").
|
|
19
|
+
USER_VERIFICATION_LEVELS = %w[required preferred discouraged].freeze
|
|
20
|
+
|
|
21
|
+
# Human-readable name of the relying party (your app), shown by some
|
|
22
|
+
# authenticators during the ceremony.
|
|
23
|
+
attr_accessor :rp_name
|
|
24
|
+
|
|
25
|
+
# The relying party id (effectively your domain). When nil, webauthn-ruby
|
|
26
|
+
# derives it from the request origin.
|
|
27
|
+
attr_accessor :rp_id
|
|
28
|
+
|
|
29
|
+
# Array of origins the server will accept assertions from, e.g.
|
|
30
|
+
# ["https://acme.example"]. Left nil only for local development; required
|
|
31
|
+
# (https-only) in any deployed environment — see +validate!+.
|
|
32
|
+
attr_accessor :allowed_origins
|
|
33
|
+
|
|
34
|
+
# User-verification requirement for both ceremonies. "required" is what
|
|
35
|
+
# makes a single passkey gesture genuinely multi-factor. Read via the
|
|
36
|
+
# accessor; always one of USER_VERIFICATION_LEVELS (see the writer).
|
|
37
|
+
attr_reader :user_verification
|
|
38
|
+
|
|
39
|
+
# Base session key from which the per-ceremony challenge keys are derived
|
|
40
|
+
# (see +registration_challenge_key+ / +authentication_challenge_key+).
|
|
41
|
+
attr_accessor :session_key
|
|
42
|
+
|
|
43
|
+
# How long (in seconds) a stashed challenge stays valid. A ceremony should
|
|
44
|
+
# complete within moments; a challenge consumed hours after it was issued
|
|
45
|
+
# points at a stale tab or a replay attempt, so it is rejected with
|
|
46
|
+
# Passkeyed::ChallengeExpired. Also advertised to the browser as the
|
|
47
|
+
# ceremony timeout (via the relying party), so the credential prompt and
|
|
48
|
+
# the server agree on the lifetime. Read via the accessor; always a
|
|
49
|
+
# positive Integer (see the writer).
|
|
50
|
+
attr_reader :challenge_timeout
|
|
51
|
+
|
|
52
|
+
def initialize
|
|
53
|
+
@rp_name = "Passkeyed"
|
|
54
|
+
@rp_id = nil
|
|
55
|
+
@allowed_origins = nil
|
|
56
|
+
self.user_verification = "required"
|
|
57
|
+
@session_key = :passkeyed_challenge
|
|
58
|
+
self.challenge_timeout = 300
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Normalize and validate on assignment so that, e.g., +:required+ (symbol)
|
|
62
|
+
# or "Required" can't slip through as a value that is advertised to the
|
|
63
|
+
# client but not enforced by +verify+. Raises on anything outside the enum.
|
|
64
|
+
def user_verification=(value)
|
|
65
|
+
normalized = value.to_s.downcase
|
|
66
|
+
unless USER_VERIFICATION_LEVELS.include?(normalized)
|
|
67
|
+
raise Passkeyed::ConfigurationError,
|
|
68
|
+
"user_verification must be one of #{USER_VERIFICATION_LEVELS.join(", ")}; got #{value.inspect}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
@user_verification = normalized
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Validate on assignment so a nonsensical timeout (zero, negative, "5m")
|
|
75
|
+
# fails at boot rather than expiring every challenge or none.
|
|
76
|
+
def challenge_timeout=(value)
|
|
77
|
+
seconds = begin
|
|
78
|
+
Integer(value)
|
|
79
|
+
rescue TypeError, ArgumentError
|
|
80
|
+
raise Passkeyed::ConfigurationError,
|
|
81
|
+
"challenge_timeout must be a positive number of seconds; got #{value.inspect}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
unless seconds.positive?
|
|
85
|
+
raise Passkeyed::ConfigurationError,
|
|
86
|
+
"challenge_timeout must be a positive number of seconds; got #{value.inspect}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
@challenge_timeout = seconds
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Distinct session keys for the two ceremonies, derived from +session_key+.
|
|
93
|
+
# Keeping them separate means a registration and an authentication ceremony
|
|
94
|
+
# running concurrently in the same session (a background tab, a stale form)
|
|
95
|
+
# can't overwrite each other's single-use challenge.
|
|
96
|
+
def registration_challenge_key
|
|
97
|
+
:"#{session_key}_registration"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def authentication_challenge_key
|
|
101
|
+
:"#{session_key}_authentication"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The WebAuthn::RelyingParty these settings describe, passed explicitly to
|
|
105
|
+
# every webauthn-ruby entry point. Keeping it off WebAuthn's global
|
|
106
|
+
# configuration means a host app (or another gem) using webauthn-ruby
|
|
107
|
+
# directly is never clobbered. Built fresh on each call — the object is
|
|
108
|
+
# cheap, and this way later configuration writes are always reflected.
|
|
109
|
+
def relying_party
|
|
110
|
+
WebAuthn::RelyingParty.new(
|
|
111
|
+
name: rp_name,
|
|
112
|
+
id: rp_id,
|
|
113
|
+
allowed_origins: allowed_origins,
|
|
114
|
+
# Also advertised to the browser as the ceremony timeout, so the
|
|
115
|
+
# credential prompt can't outlive the server-side challenge.
|
|
116
|
+
credential_options_timeout: challenge_timeout * 1000
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Refuse to boot a deployed app with development origins. WebAuthn requires
|
|
121
|
+
# a secure context, so a blank allow-list or an http:// origin outside
|
|
122
|
+
# development and test is always a misconfiguration — most often the
|
|
123
|
+
# generated localhost default left in place. Fail loud at boot rather than
|
|
124
|
+
# silently rejecting every sign-in (or, worse, trusting an insecure
|
|
125
|
+
# origin). Runs in every non-local environment (production, staging, any
|
|
126
|
+
# custom deploy env); no-op in development/test and when Rails isn't
|
|
127
|
+
# loaded (e.g. the gem's own suite).
|
|
128
|
+
def validate!
|
|
129
|
+
return unless enforce_secure_origins?
|
|
130
|
+
|
|
131
|
+
if allowed_origins.blank?
|
|
132
|
+
raise Passkeyed::ConfigurationError,
|
|
133
|
+
"allowed_origins is required outside development and test; " \
|
|
134
|
+
"set config.allowed_origins to your HTTPS origin(s)"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
insecure = allowed_origins.reject { |origin| origin.to_s.start_with?("https://") }
|
|
138
|
+
return if insecure.empty?
|
|
139
|
+
|
|
140
|
+
raise Passkeyed::ConfigurationError,
|
|
141
|
+
"allowed_origins must use https outside development and test; got #{insecure.inspect} " \
|
|
142
|
+
"(the generated localhost default cannot be used in a deployed environment)"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# True in any deployed environment: everything except Rails.env.local?
|
|
148
|
+
# (development and test, per Rails >= 7.1 — the gem's declared floor).
|
|
149
|
+
def enforce_secure_origins?
|
|
150
|
+
defined?(Rails) && Rails.respond_to?(:env) && !Rails.env.local?
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
class << self
|
|
155
|
+
# The ActiveRecord base class Passkeyed::Credential inherits from. Defaults
|
|
156
|
+
# to the host app's ApplicationRecord when defined (so multi-database
|
|
157
|
+
# connects_to/role config is inherited), falling back to ActiveRecord::Base.
|
|
158
|
+
# Set in an initializer to override. Accepts a class or a class name string.
|
|
159
|
+
#
|
|
160
|
+
# Ordering matters: the parent class is resolved once, when Credential is
|
|
161
|
+
# first autoloaded, so assign this before anything references
|
|
162
|
+
# Passkeyed::Credential (an initializer is early enough; a later assignment
|
|
163
|
+
# is silently ignored).
|
|
164
|
+
def base_record_class
|
|
165
|
+
klass = @base_record_class || (defined?(ApplicationRecord) ? ApplicationRecord : ActiveRecord::Base)
|
|
166
|
+
klass.is_a?(String) ? klass.constantize : klass
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
attr_writer :base_record_class
|
|
170
|
+
|
|
171
|
+
def configuration
|
|
172
|
+
@configuration ||= Configuration.new
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Yields the configuration for editing, then validates it. Call once during
|
|
176
|
+
# boot (an initializer).
|
|
177
|
+
def configure
|
|
178
|
+
yield(configuration) if block_given?
|
|
179
|
+
configuration.validate!
|
|
180
|
+
configuration
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Mainly for tests: forget all configuration.
|
|
184
|
+
def reset_configuration!
|
|
185
|
+
@configuration = Configuration.new
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|