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.
@@ -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