diff --git a/CHANGELOG.md b/CHANGELOG.md index 32fd41fc..a135e19c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * Remove system tests * Use native rate_limit for lockable +* Copy web_authn_controller.js instead of depend on stimulus-web-authn ## Authentication Zero 3.0.2 ## diff --git a/lib/generators/authentication/authentication_generator.rb b/lib/generators/authentication/authentication_generator.rb index 95709547..264e21d9 100644 --- a/lib/generators/authentication/authentication_generator.rb +++ b/lib/generators/authentication/authentication_generator.rb @@ -123,9 +123,9 @@ def create_controllers def install_javascript return unless webauthn? - copy_file "javascript/controllers/application.js", "app/javascript/controllers/application.js", force: true - run "bin/importmap pin stimulus-web-authn" if importmaps? - run "yarn add stimulus-web-authn" if node? + copy_file "javascript/controllers/web_authn_controller.js", "app/javascript/controllers/web_authn_controller.js" + run "bin/importmap pin @rails/request.js" if importmaps? + run "yarn add @rails/request.js" if node? end def create_views diff --git a/lib/generators/authentication/templates/javascript/controllers/application.js b/lib/generators/authentication/templates/javascript/controllers/application.js deleted file mode 100644 index 1d926319..00000000 --- a/lib/generators/authentication/templates/javascript/controllers/application.js +++ /dev/null @@ -1,11 +0,0 @@ -import { Application } from "@hotwired/stimulus" -import WebAuthnController from "stimulus-web-authn" - -const application = Application.start() -application.register("web-authn", WebAuthnController) - -// Configure Stimulus development experience -application.debug = false -window.Stimulus = application - -export { application } diff --git a/lib/generators/authentication/templates/javascript/controllers/web_authn_controller.js b/lib/generators/authentication/templates/javascript/controllers/web_authn_controller.js new file mode 100644 index 00000000..29acddea --- /dev/null +++ b/lib/generators/authentication/templates/javascript/controllers/web_authn_controller.js @@ -0,0 +1,111 @@ +import { Controller } from "@hotwired/stimulus" +import { create, get, supported } from "@github/webauthn-json" +import { FetchRequest } from "@rails/request.js" + +export default class WebAuthnController extends Controller { + static targets = [ "error", "button", "supportText" ] + static classes = [ "loading" ] + static values = { + challengeUrl: String, + verificationUrl: String, + fallbackUrl: String, + retryText: { type: String, default: "Retry" }, + notAllowedText: { type: String, default: "That didn't work. Either it was cancelled or took too long. Please try again." }, + invalidStateText: { type: String, default: "We couldn't add that security key. Please confirm you haven't already registered it, then try again." } + } + + connect() { + if (!supported()) { + this.handleUnsupportedBrowser() + } + } + + getCredential() { + this.hideError() + this.disableForm() + this.requestChallengeAndVerify(get) + } + + createCredential() { + this.hideError() + this.disableForm() + this.requestChallengeAndVerify(create) + } + + // Private + + handleUnsupportedBrowser() { + this.buttonTarget.parentNode.removeChild(this.buttonTarget) + + if (this.fallbackUrlValue) { + window.location.replace(this.fallbackUrlValue) + } else { + this.supportTextTargets.forEach(target => target.hidden = !target.hidden) + } + } + + async requestChallengeAndVerify(fn) { + try { + const challengeResponse = await this.requestPublicKeyChallenge() + const credentialResponse = await fn({ publicKey: challengeResponse }) + this.onCompletion(await this.verify(credentialResponse)) + } catch (error) { + this.onError(error) + } + } + + async requestPublicKeyChallenge() { + return await this.request("get", this.challengeUrlValue) + } + + async verify(credentialResponse) { + return await this.request("post", this.verificationUrlValue, { + body: JSON.stringify({ credential: credentialResponse }), + contentType: "application/json", + responseKind: "json" + }) + } + + onCompletion(response) { + window.location.replace(response.location) + } + + onError(error) { + if (error.code === 0 && error.name === "NotAllowedError") { + this.errorTarget.textContent = this.notAllowedTextValue + } else if (error.code === 11 && error.name === "InvalidStateError") { + this.errorTarget.textContent = this.invalidStateTextValue + } else { + this.errorTarget.textContent = error.message + } + this.showError() + } + + hideError() { + if (this.hasErrorTarget) this.errorTarget.hidden = true + } + + showError() { + if (this.hasErrorTarget) { + this.errorTarget.hidden = false + this.buttonTarget.textContent = this.retryTextValue + this.enableForm() + } + } + + enableForm() { + this.element.classList.remove(this.loadingClass) + this.buttonTarget.disabled = false + } + + disableForm() { + this.element.classList.add(this.loadingClass) + this.buttonTarget.disabled = true + } + + async request(method, url, options) { + const request = new FetchRequest(method, url, { responseKind: "json", ...options }) + const response = await request.perform() + return response.json + } +}