has_secure_passkey 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +32 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/javascripts/@github--webauthn-json.js +1 -0
  6. data/app/assets/javascripts/@rails--request.js +2 -0
  7. data/app/assets/javascripts/components/has_secure_passkey.js +3 -0
  8. data/app/assets/javascripts/components/web_authn.js +81 -0
  9. data/app/assets/javascripts/has_secure_passkey.js +406 -0
  10. data/app/assets/stylesheets/has_secure_passkey/application.css +15 -0
  11. data/app/controllers/has_secure_passkey/application_controller.rb +4 -0
  12. data/app/controllers/has_secure_passkey/challenges_controller.rb +6 -0
  13. data/app/helpers/has_secure_passkey/application_helper.rb +32 -0
  14. data/app/mailers/has_secure_passkey/application_mailer.rb +6 -0
  15. data/app/models/has_secure_passkey/application_record.rb +5 -0
  16. data/app/views/has_secure_passkey/challenges/create.turbo_stream.erb +5 -0
  17. data/app/views/layouts/has_secure_passkey/application.html.erb +17 -0
  18. data/config/importmap.rb +1 -0
  19. data/config/routes.rb +3 -0
  20. data/lib/generators/has_secure_passkey/passkeys/passkeys_generator.rb +59 -0
  21. data/lib/generators/has_secure_passkey/passkeys/templates/app/channels/application_cable/connection.rb.tt +16 -0
  22. data/lib/generators/has_secure_passkey/passkeys/templates/app/controllers/concerns/authentication.rb.tt +59 -0
  23. data/lib/generators/has_secure_passkey/passkeys/templates/app/controllers/email_verifications_controller.rb.tt +7 -0
  24. data/lib/generators/has_secure_passkey/passkeys/templates/app/controllers/people_controller.rb.tt +11 -0
  25. data/lib/generators/has_secure_passkey/passkeys/templates/app/controllers/registrations_controller.rb.tt +20 -0
  26. data/lib/generators/has_secure_passkey/passkeys/templates/app/controllers/sessions_controller.rb.tt +20 -0
  27. data/lib/generators/has_secure_passkey/passkeys/templates/app/mailers/email_verification_mailer.rb.tt +7 -0
  28. data/lib/generators/has_secure_passkey/passkeys/templates/app/models/current.rb.tt +4 -0
  29. data/lib/generators/has_secure_passkey/passkeys/templates/app/models/passkey.rb.tt +30 -0
  30. data/lib/generators/has_secure_passkey/passkeys/templates/app/models/person.rb.tt +9 -0
  31. data/lib/generators/has_secure_passkey/passkeys/templates/app/models/session.rb.tt +7 -0
  32. data/lib/generators/has_secure_passkey/passkeys/templates/app/views/email_verification_mailer/verify.html.erb.tt +1 -0
  33. data/lib/generators/has_secure_passkey/passkeys/templates/app/views/email_verification_mailer/verify.text.erb.tt +1 -0
  34. data/lib/generators/has_secure_passkey/passkeys/templates/app/views/email_verifications/show.html.erb.tt +11 -0
  35. data/lib/generators/has_secure_passkey/passkeys/templates/app/views/registrations/create.turbo_stream.erb.tt +7 -0
  36. data/lib/generators/has_secure_passkey/passkeys/templates/app/views/registrations/new.html.erb.tt +16 -0
  37. data/lib/generators/has_secure_passkey/passkeys/templates/test/mailers/previews/email_verification_mailer_preview.rb.tt +7 -0
  38. data/lib/has_secure_passkey/active_record_helpers.rb +48 -0
  39. data/lib/has_secure_passkey/add_passkey.rb +45 -0
  40. data/lib/has_secure_passkey/authenticate_by.rb +49 -0
  41. data/lib/has_secure_passkey/engine.rb +41 -0
  42. data/lib/has_secure_passkey/options_for_create.rb +49 -0
  43. data/lib/has_secure_passkey/options_for_get.rb +34 -0
  44. data/lib/has_secure_passkey/version.rb +3 -0
  45. data/lib/has_secure_passkey.rb +12 -0
  46. data/lib/tasks/has_secure_passkey_tasks.rake +5 -0
  47. metadata +113 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 11ce9f55e205c9be680e02f1159a9880bde3abae369530bf6423235f90b7fbf0
4
+ data.tar.gz: 93f6d76763e8fc4910de588288daf9ce8451070c0bfc615fd8ab2a691f3c6898
5
+ SHA512:
6
+ metadata.gz: 770fbbcf695704aa6e065f3b94ae239e5b91bafef8ac70c96aa949553987403186dfa31015c7101dad74b56b85b5abb7a1cacfd8d80424522e532bae24348ae1
7
+ data.tar.gz: 5abfed79a375a27985383b2235efd4d522b0a10449c22a186a1c257dd45a2c714b138b12c2da26cc4bea2271569acc38570f548b754a7f2370251a18b119ed55
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Nick Pezza
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # HasSecurePasskey
2
+
3
+ HasSecurePasskey is part generator, part cinceptual compression of a passkey
4
+ implementation. Implementing passkeys is more complex than regular passwords
5
+ but they have the added benefit of being more secure and easier for end users
6
+ to use.
7
+
8
+ ## Usage
9
+
10
+ If you want to use the full implementation run the `bin/rails passkeys`
11
+ generator which sets up models, controllers, and a mailer for the whole
12
+ registration and session flow.
13
+
14
+ Alternatively, you can just use the helpers provided by `has_secure_passkey` and
15
+ the view helpers and make everything else yourself.
16
+
17
+
18
+ ## Installation
19
+ Add this line to your application's Gemfile:
20
+
21
+ ```ruby
22
+ gem "has_secure_passkey"
23
+ ```
24
+
25
+ And then execute:
26
+ ```bash
27
+ $ bundle
28
+ $ bin/rails passkeys
29
+ ```
30
+
31
+ ## License
32
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1 @@
1
+ function base64urlToBuffer(e){const r="==".slice(0,(4-e.length%4)%4);const t=e.replace(/-/g,"+").replace(/_/g,"/")+r;const n=atob(t);const i=new ArrayBuffer(n.length);const o=new Uint8Array(i);for(let e=0;e<n.length;e++)o[e]=n.charCodeAt(e);return i}function bufferToBase64url(e){const r=new Uint8Array(e);let t="";for(const e of r)t+=String.fromCharCode(e);const n=btoa(t);const i=n.replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"");return i}var e="copy";var r="convert";function convert(t,n,i){if(n===e)return i;if(n===r)return t(i);if(n instanceof Array)return i.map((e=>convert(t,n[0],e)));if(n instanceof Object){const e={};for(const[r,o]of Object.entries(n)){if(o.derive){const e=o.derive(i);void 0!==e&&(i[r]=e)}if(r in i)null!=i[r]?e[r]=convert(t,o.schema,i[r]):e[r]=null;else if(o.required)throw new Error(`Missing key: ${r}`)}return e}}function derived(e,r){return{required:true,schema:e,derive:r}}function required(e){return{required:true,schema:e}}function optional(e){return{required:false,schema:e}}var t={type:required(e),id:required(r),transports:optional(e)};var n={appid:optional(e),appidExclude:optional(e),credProps:optional(e)};var i={appid:optional(e),appidExclude:optional(e),credProps:optional(e)};var o={publicKey:required({rp:required(e),user:required({id:required(r),name:required(e),displayName:required(e)}),challenge:required(r),pubKeyCredParams:required(e),timeout:optional(e),excludeCredentials:optional([t]),authenticatorSelection:optional(e),attestation:optional(e),extensions:optional(n)}),signal:optional(e)};var a={type:required(e),id:required(e),rawId:required(r),authenticatorAttachment:optional(e),response:required({clientDataJSON:required(r),attestationObject:required(r),transports:derived(e,(e=>{var r;return(null==(r=e.getTransports)?void 0:r.call(e))||[]}))}),clientExtensionResults:derived(i,(e=>e.getClientExtensionResults()))};var u={mediation:optional(e),publicKey:required({challenge:required(r),timeout:optional(e),rpId:optional(e),allowCredentials:optional([t]),userVerification:optional(e),extensions:optional(n)}),signal:optional(e)};var s={type:required(e),id:required(e),rawId:required(r),authenticatorAttachment:optional(e),response:required({clientDataJSON:required(r),authenticatorData:required(r),signature:required(r),userHandle:required(r)}),clientExtensionResults:derived(i,(e=>e.getClientExtensionResults()))};var c={credentialCreationOptions:o,publicKeyCredentialWithAttestation:a,credentialRequestOptions:u,publicKeyCredentialWithAssertion:s};function createRequestFromJSON(e){return convert(base64urlToBuffer,o,e)}function createResponseToJSON(e){return convert(bufferToBase64url,a,e)}async function create(e){const r=await navigator.credentials.create(createRequestFromJSON(e));return createResponseToJSON(r)}function getRequestFromJSON(e){return convert(base64urlToBuffer,u,e)}function getResponseToJSON(e){return convert(bufferToBase64url,s,e)}async function get(e){const r=await navigator.credentials.get(getRequestFromJSON(e));return getResponseToJSON(r)}function supported(){return!!(navigator.credentials&&navigator.credentials.create&&navigator.credentials.get&&window.PublicKeyCredential)}export{create,get,c as schema,supported};
@@ -0,0 +1,2 @@
1
+ class FetchResponse{constructor(t){this.response=t}get statusCode(){return this.response.status}get redirected(){return this.response.redirected}get ok(){return this.response.ok}get unauthenticated(){return this.statusCode===401}get unprocessableEntity(){return this.statusCode===422}get authenticationURL(){return this.response.headers.get("WWW-Authenticate")}get contentType(){const t=this.response.headers.get("Content-Type")||"";return t.replace(/;.*$/,"")}get headers(){return this.response.headers}get html(){return this.contentType.match(/^(application|text)\/(html|xhtml\+xml)$/)?this.text:Promise.reject(new Error(`Expected an HTML response but got "${this.contentType}" instead`))}get json(){return this.contentType.match(/^application\/.*json$/)?this.responseJson||(this.responseJson=this.response.json()):Promise.reject(new Error(`Expected a JSON response but got "${this.contentType}" instead`))}get text(){return this.responseText||(this.responseText=this.response.text())}get isTurboStream(){return this.contentType.match(/^text\/vnd\.turbo-stream\.html/)}get isScript(){return this.contentType.match(/\b(?:java|ecma)script\b/)}async renderTurboStream(){if(!this.isTurboStream)return Promise.reject(new Error(`Expected a Turbo Stream response but got "${this.contentType}" instead`));window.Turbo?await window.Turbo.renderStreamMessage(await this.text):console.warn("You must set `window.Turbo = Turbo` to automatically process Turbo Stream events with request.js")}async activeScript(){if(!this.isScript)return Promise.reject(new Error(`Expected a Script response but got "${this.contentType}" instead`));{const t=document.createElement("script");const e=document.querySelector("meta[name=csp-nonce]");const n=e&&e.content;n&&t.setAttribute("nonce",n);t.innerHTML=await this.text;document.body.appendChild(t)}}}class RequestInterceptor{static register(t){this.interceptor=t}static get(){return this.interceptor}static reset(){this.interceptor=void 0}}function getCookie(t){const e=document.cookie?document.cookie.split("; "):[];const n=`${encodeURIComponent(t)}=`;const s=e.find((t=>t.startsWith(n)));if(s){const t=s.split("=").slice(1).join("=");if(t)return decodeURIComponent(t)}}function compact(t){const e={};for(const n in t){const s=t[n];s!==void 0&&(e[n]=s)}return e}function metaContent(t){const e=document.head.querySelector(`meta[name="${t}"]`);return e&&e.content}function stringEntriesFromFormData(t){return[...t].reduce(((t,[e,n])=>t.concat(typeof n==="string"?[[e,n]]:[])),[])}function mergeEntries(t,e){for(const[n,s]of e)if(!(s instanceof window.File))if(t.has(n)&&!n.includes("[]")){t.delete(n);t.set(n,s)}else t.append(n,s)}class FetchRequest{constructor(t,e,n={}){this.method=t;this.options=n;this.originalUrl=e.toString()}async perform(){try{const t=RequestInterceptor.get();t&&await t(this)}catch(t){console.error(t)}const t=this.responseKind==="turbo-stream"&&window.Turbo?window.Turbo.fetch:window.fetch;const e=new FetchResponse(await t(this.url,this.fetchOptions));if(e.unauthenticated&&e.authenticationURL)return Promise.reject(window.location.href=e.authenticationURL);e.isScript&&await e.activeScript();const n=e.ok||e.unprocessableEntity;n&&e.isTurboStream&&await e.renderTurboStream();return e}addHeader(t,e){const n=this.additionalHeaders;n[t]=e;this.options.headers=n}sameHostname(){if(!this.originalUrl.startsWith("http:"))return true;try{return new URL(this.originalUrl).hostname===window.location.hostname}catch(t){return true}}get fetchOptions(){return{method:this.method.toUpperCase(),headers:this.headers,body:this.formattedBody,signal:this.signal,credentials:this.credentials,redirect:this.redirect}}get headers(){const t={"X-Requested-With":"XMLHttpRequest","Content-Type":this.contentType,Accept:this.accept};this.sameHostname()&&(t["X-CSRF-Token"]=this.csrfToken);return compact(Object.assign(t,this.additionalHeaders))}get csrfToken(){return getCookie(metaContent("csrf-param"))||metaContent("csrf-token")}get contentType(){return this.options.contentType?this.options.contentType:this.body==null||this.body instanceof window.FormData?void 0:this.body instanceof window.File?this.body.type:"application/json"}get accept(){switch(this.responseKind){case"html":return"text/html, application/xhtml+xml";case"turbo-stream":return"text/vnd.turbo-stream.html, text/html, application/xhtml+xml";case"json":return"application/json, application/vnd.api+json";case"script":return"text/javascript, application/javascript";default:return"*/*"}}get body(){return this.options.body}get query(){const t=(this.originalUrl.split("?")[1]||"").split("#")[0];const e=new URLSearchParams(t);let n=this.options.query;n=n instanceof window.FormData?stringEntriesFromFormData(n):n instanceof window.URLSearchParams?n.entries():Object.entries(n||{});mergeEntries(e,n);const s=e.toString();return s.length>0?`?${s}`:""}get url(){return this.originalUrl.split("?")[0].split("#")[0]+this.query}get responseKind(){return this.options.responseKind||"html"}get signal(){return this.options.signal}get redirect(){return this.options.redirect||"follow"}get credentials(){return this.options.credentials||"same-origin"}get additionalHeaders(){return this.options.headers||{}}get formattedBody(){const t=Object.prototype.toString.call(this.body)==="[object String]";const e=this.headers["Content-Type"]==="application/json";return e&&!t?JSON.stringify(this.body):this.body}}async function get(t,e){const n=new FetchRequest("get",t,e);return n.perform()}async function post(t,e){const n=new FetchRequest("post",t,e);return n.perform()}async function put(t,e){const n=new FetchRequest("put",t,e);return n.perform()}async function patch(t,e){const n=new FetchRequest("patch",t,e);return n.perform()}async function destroy(t,e){const n=new FetchRequest("delete",t,e);return n.perform()}export{FetchRequest,FetchResponse,RequestInterceptor,destroy,get,patch,post,put};
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,3 @@
1
+ import WebAuthn from "./web_authn"
2
+
3
+ customElements.define("web-authn", WebAuthn);
@@ -0,0 +1,81 @@
1
+ import * as WebAuthnJSON from "../@github--webauthn-json"
2
+ import { post } from "../@rails--request"
3
+
4
+ export default class WebAuthn extends HTMLElement {
5
+ static observedAttributes = ["action", "callback", "options"];
6
+
7
+ constructor() {
8
+ super();
9
+ }
10
+
11
+ connectedCallback() {
12
+ this.progressBar = Turbo.navigator.delegate.adapter.progressBar
13
+ this.style.display = "none"
14
+ this.setAttribute("id", "webauthn")
15
+ this.setAttribute("data-turbo-temporary", 1)
16
+ this.run()
17
+ }
18
+
19
+ run() {
20
+ WebAuthnJSON[this.action](this.options).
21
+ then(async (credential) => {
22
+ this.showProgress()
23
+
24
+ const { response, redirected } = await post(this.callback, {
25
+ responseKind: "turbo-stream",
26
+ body: JSON.stringify(Object.assign(credential, { webauthn_message: this.message }))
27
+ })
28
+
29
+ this.hideProgress()
30
+
31
+ if (response.ok && redirected) {
32
+ const responseHTML = await response.text()
33
+ const options = { action: "advance", shouldCacheSnapshot: false,
34
+ response: { statusCode: response.status, responseHTML, redirected } }
35
+ Turbo.navigator.proposeVisit(new URL(response.url), options)
36
+ }
37
+ }).catch((error) => {
38
+ this.onError(error)
39
+ })
40
+ }
41
+
42
+ showProgress() {
43
+ this.progressBar.setValue(0)
44
+ this.progressBar.show()
45
+ }
46
+
47
+ hideProgress() {
48
+ this.progressBar.setValue(1)
49
+ this.progressBar.hide()
50
+ }
51
+
52
+ onError(error) {
53
+ let event
54
+
55
+ if (error.code === 0 && error.name === "NotAllowedError") {
56
+ event = new CustomEvent("web-authn-error", { bubbles: true, detail: "That didn't work. Either it was cancelled or took too long. Please try again." });
57
+ } else if (error.code === 11 && error.name === "InvalidStateError") {
58
+ event = new CustomEvent("web-authn-error", { bubbles: true, detail: "We couldn't add that security key. Looks like you may have already registered it." });
59
+ } else {
60
+ event = new CustomEvent("web-authn-error", { bubbles: true, detail: error.message });
61
+ }
62
+
63
+ this.dispatchEvent(event);
64
+ }
65
+
66
+ get action() {
67
+ return this.getAttribute('action');
68
+ }
69
+
70
+ get callback() {
71
+ return this.getAttribute('callback');
72
+ }
73
+
74
+ get options() {
75
+ return JSON.parse(this.getAttribute('options'));
76
+ }
77
+
78
+ get message() {
79
+ return this.getAttribute('message');
80
+ }
81
+ }
@@ -0,0 +1,406 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __export = (target, all) => {
3
+ for (var name in all)
4
+ __defProp(target, name, {
5
+ get: all[name],
6
+ enumerable: true,
7
+ configurable: true,
8
+ set: (newValue) => all[name] = () => newValue
9
+ });
10
+ };
11
+
12
+ // app/assets/javascripts/@github--webauthn-json.js
13
+ var exports__github_webauthn_json = {};
14
+ __export(exports__github_webauthn_json, {
15
+ supported: () => {
16
+ {
17
+ return supported;
18
+ }
19
+ },
20
+ schema: () => {
21
+ {
22
+ return c;
23
+ }
24
+ },
25
+ get: () => {
26
+ {
27
+ return get;
28
+ }
29
+ },
30
+ create: () => {
31
+ {
32
+ return create;
33
+ }
34
+ }
35
+ });
36
+ var base64urlToBuffer = function(e) {
37
+ const r = "==".slice(0, (4 - e.length % 4) % 4);
38
+ const t = e.replace(/-/g, "+").replace(/_/g, "/") + r;
39
+ const n = atob(t);
40
+ const i = new ArrayBuffer(n.length);
41
+ const o = new Uint8Array(i);
42
+ for (let e2 = 0;e2 < n.length; e2++)
43
+ o[e2] = n.charCodeAt(e2);
44
+ return i;
45
+ };
46
+ var bufferToBase64url = function(e) {
47
+ const r = new Uint8Array(e);
48
+ let t = "";
49
+ for (const e2 of r)
50
+ t += String.fromCharCode(e2);
51
+ const n = btoa(t);
52
+ const i = n.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
53
+ return i;
54
+ };
55
+ var convert = function(t, n, i) {
56
+ if (n === e)
57
+ return i;
58
+ if (n === r)
59
+ return t(i);
60
+ if (n instanceof Array)
61
+ return i.map((e) => convert(t, n[0], e));
62
+ if (n instanceof Object) {
63
+ const e = {};
64
+ for (const [r, o] of Object.entries(n)) {
65
+ if (o.derive) {
66
+ const e2 = o.derive(i);
67
+ e2 !== undefined && (i[r] = e2);
68
+ }
69
+ if (r in i)
70
+ i[r] != null ? e[r] = convert(t, o.schema, i[r]) : e[r] = null;
71
+ else if (o.required)
72
+ throw new Error(`Missing key: ${r}`);
73
+ }
74
+ return e;
75
+ }
76
+ };
77
+ var derived = function(e, r) {
78
+ return { required: true, schema: e, derive: r };
79
+ };
80
+ var required = function(e) {
81
+ return { required: true, schema: e };
82
+ };
83
+ var optional = function(e) {
84
+ return { required: false, schema: e };
85
+ };
86
+ var createRequestFromJSON = function(e) {
87
+ return convert(base64urlToBuffer, o, e);
88
+ };
89
+ var createResponseToJSON = function(e) {
90
+ return convert(bufferToBase64url, a, e);
91
+ };
92
+ async function create(e) {
93
+ const r = await navigator.credentials.create(createRequestFromJSON(e));
94
+ return createResponseToJSON(r);
95
+ }
96
+ var getRequestFromJSON = function(e) {
97
+ return convert(base64urlToBuffer, u, e);
98
+ };
99
+ var getResponseToJSON = function(e) {
100
+ return convert(bufferToBase64url, s, e);
101
+ };
102
+ async function get(e) {
103
+ const r = await navigator.credentials.get(getRequestFromJSON(e));
104
+ return getResponseToJSON(r);
105
+ }
106
+ var supported = function() {
107
+ return !!(navigator.credentials && navigator.credentials.create && navigator.credentials.get && window.PublicKeyCredential);
108
+ };
109
+ var e = "copy";
110
+ var r = "convert";
111
+ var t = { type: required(e), id: required(r), transports: optional(e) };
112
+ var n = { appid: optional(e), appidExclude: optional(e), credProps: optional(e) };
113
+ var i = { appid: optional(e), appidExclude: optional(e), credProps: optional(e) };
114
+ var o = { publicKey: required({ rp: required(e), user: required({ id: required(r), name: required(e), displayName: required(e) }), challenge: required(r), pubKeyCredParams: required(e), timeout: optional(e), excludeCredentials: optional([t]), authenticatorSelection: optional(e), attestation: optional(e), extensions: optional(n) }), signal: optional(e) };
115
+ var a = { type: required(e), id: required(e), rawId: required(r), authenticatorAttachment: optional(e), response: required({ clientDataJSON: required(r), attestationObject: required(r), transports: derived(e, (e2) => {
116
+ var r2;
117
+ return ((r2 = e2.getTransports) == null ? undefined : r2.call(e2)) || [];
118
+ }) }), clientExtensionResults: derived(i, (e2) => e2.getClientExtensionResults()) };
119
+ var u = { mediation: optional(e), publicKey: required({ challenge: required(r), timeout: optional(e), rpId: optional(e), allowCredentials: optional([t]), userVerification: optional(e), extensions: optional(n) }), signal: optional(e) };
120
+ var s = { type: required(e), id: required(e), rawId: required(r), authenticatorAttachment: optional(e), response: required({ clientDataJSON: required(r), authenticatorData: required(r), signature: required(r), userHandle: required(r) }), clientExtensionResults: derived(i, (e2) => e2.getClientExtensionResults()) };
121
+ var c = { credentialCreationOptions: o, publicKeyCredentialWithAttestation: a, credentialRequestOptions: u, publicKeyCredentialWithAssertion: s };
122
+
123
+ // app/assets/javascripts/@rails--request.js
124
+ var getCookie = function(t2) {
125
+ const e2 = document.cookie ? document.cookie.split("; ") : [];
126
+ const n2 = `${encodeURIComponent(t2)}=`;
127
+ const s2 = e2.find((t3) => t3.startsWith(n2));
128
+ if (s2) {
129
+ const t3 = s2.split("=").slice(1).join("=");
130
+ if (t3)
131
+ return decodeURIComponent(t3);
132
+ }
133
+ };
134
+ var compact = function(t2) {
135
+ const e2 = {};
136
+ for (const n2 in t2) {
137
+ const s2 = t2[n2];
138
+ s2 !== undefined && (e2[n2] = s2);
139
+ }
140
+ return e2;
141
+ };
142
+ var metaContent = function(t2) {
143
+ const e2 = document.head.querySelector(`meta[name="${t2}"]`);
144
+ return e2 && e2.content;
145
+ };
146
+ var stringEntriesFromFormData = function(t2) {
147
+ return [...t2].reduce((t3, [e2, n2]) => t3.concat(typeof n2 === "string" ? [[e2, n2]] : []), []);
148
+ };
149
+ var mergeEntries = function(t2, e2) {
150
+ for (const [n2, s2] of e2)
151
+ if (!(s2 instanceof window.File))
152
+ if (t2.has(n2) && !n2.includes("[]")) {
153
+ t2.delete(n2);
154
+ t2.set(n2, s2);
155
+ } else
156
+ t2.append(n2, s2);
157
+ };
158
+ async function post(t2, e2) {
159
+ const n2 = new FetchRequest("post", t2, e2);
160
+ return n2.perform();
161
+ }
162
+ class FetchResponse {
163
+ constructor(t2) {
164
+ this.response = t2;
165
+ }
166
+ get statusCode() {
167
+ return this.response.status;
168
+ }
169
+ get redirected() {
170
+ return this.response.redirected;
171
+ }
172
+ get ok() {
173
+ return this.response.ok;
174
+ }
175
+ get unauthenticated() {
176
+ return this.statusCode === 401;
177
+ }
178
+ get unprocessableEntity() {
179
+ return this.statusCode === 422;
180
+ }
181
+ get authenticationURL() {
182
+ return this.response.headers.get("WWW-Authenticate");
183
+ }
184
+ get contentType() {
185
+ const t2 = this.response.headers.get("Content-Type") || "";
186
+ return t2.replace(/;.*$/, "");
187
+ }
188
+ get headers() {
189
+ return this.response.headers;
190
+ }
191
+ get html() {
192
+ return this.contentType.match(/^(application|text)\/(html|xhtml\+xml)$/) ? this.text : Promise.reject(new Error(`Expected an HTML response but got "${this.contentType}" instead`));
193
+ }
194
+ get json() {
195
+ return this.contentType.match(/^application\/.*json$/) ? this.responseJson || (this.responseJson = this.response.json()) : Promise.reject(new Error(`Expected a JSON response but got "${this.contentType}" instead`));
196
+ }
197
+ get text() {
198
+ return this.responseText || (this.responseText = this.response.text());
199
+ }
200
+ get isTurboStream() {
201
+ return this.contentType.match(/^text\/vnd\.turbo-stream\.html/);
202
+ }
203
+ get isScript() {
204
+ return this.contentType.match(/\b(?:java|ecma)script\b/);
205
+ }
206
+ async renderTurboStream() {
207
+ if (!this.isTurboStream)
208
+ return Promise.reject(new Error(`Expected a Turbo Stream response but got "${this.contentType}" instead`));
209
+ window.Turbo ? await window.Turbo.renderStreamMessage(await this.text) : console.warn("You must set `window.Turbo = Turbo` to automatically process Turbo Stream events with request.js");
210
+ }
211
+ async activeScript() {
212
+ if (!this.isScript)
213
+ return Promise.reject(new Error(`Expected a Script response but got "${this.contentType}" instead`));
214
+ {
215
+ const t2 = document.createElement("script");
216
+ const e2 = document.querySelector("meta[name=csp-nonce]");
217
+ const n2 = e2 && e2.content;
218
+ n2 && t2.setAttribute("nonce", n2);
219
+ t2.innerHTML = await this.text;
220
+ document.body.appendChild(t2);
221
+ }
222
+ }
223
+ }
224
+
225
+ class RequestInterceptor {
226
+ static register(t2) {
227
+ this.interceptor = t2;
228
+ }
229
+ static get() {
230
+ return this.interceptor;
231
+ }
232
+ static reset() {
233
+ this.interceptor = undefined;
234
+ }
235
+ }
236
+
237
+ class FetchRequest {
238
+ constructor(t2, e2, n2 = {}) {
239
+ this.method = t2;
240
+ this.options = n2;
241
+ this.originalUrl = e2.toString();
242
+ }
243
+ async perform() {
244
+ try {
245
+ const t3 = RequestInterceptor.get();
246
+ t3 && await t3(this);
247
+ } catch (t3) {
248
+ console.error(t3);
249
+ }
250
+ const t2 = this.responseKind === "turbo-stream" && window.Turbo ? window.Turbo.fetch : window.fetch;
251
+ const e2 = new FetchResponse(await t2(this.url, this.fetchOptions));
252
+ if (e2.unauthenticated && e2.authenticationURL)
253
+ return Promise.reject(window.location.href = e2.authenticationURL);
254
+ e2.isScript && await e2.activeScript();
255
+ const n2 = e2.ok || e2.unprocessableEntity;
256
+ n2 && e2.isTurboStream && await e2.renderTurboStream();
257
+ return e2;
258
+ }
259
+ addHeader(t2, e2) {
260
+ const n2 = this.additionalHeaders;
261
+ n2[t2] = e2;
262
+ this.options.headers = n2;
263
+ }
264
+ sameHostname() {
265
+ if (!this.originalUrl.startsWith("http:"))
266
+ return true;
267
+ try {
268
+ return new URL(this.originalUrl).hostname === window.location.hostname;
269
+ } catch (t2) {
270
+ return true;
271
+ }
272
+ }
273
+ get fetchOptions() {
274
+ return { method: this.method.toUpperCase(), headers: this.headers, body: this.formattedBody, signal: this.signal, credentials: this.credentials, redirect: this.redirect };
275
+ }
276
+ get headers() {
277
+ const t2 = { "X-Requested-With": "XMLHttpRequest", "Content-Type": this.contentType, Accept: this.accept };
278
+ this.sameHostname() && (t2["X-CSRF-Token"] = this.csrfToken);
279
+ return compact(Object.assign(t2, this.additionalHeaders));
280
+ }
281
+ get csrfToken() {
282
+ return getCookie(metaContent("csrf-param")) || metaContent("csrf-token");
283
+ }
284
+ get contentType() {
285
+ return this.options.contentType ? this.options.contentType : this.body == null || this.body instanceof window.FormData ? undefined : this.body instanceof window.File ? this.body.type : "application/json";
286
+ }
287
+ get accept() {
288
+ switch (this.responseKind) {
289
+ case "html":
290
+ return "text/html, application/xhtml+xml";
291
+ case "turbo-stream":
292
+ return "text/vnd.turbo-stream.html, text/html, application/xhtml+xml";
293
+ case "json":
294
+ return "application/json, application/vnd.api+json";
295
+ case "script":
296
+ return "text/javascript, application/javascript";
297
+ default:
298
+ return "*/*";
299
+ }
300
+ }
301
+ get body() {
302
+ return this.options.body;
303
+ }
304
+ get query() {
305
+ const t2 = (this.originalUrl.split("?")[1] || "").split("#")[0];
306
+ const e2 = new URLSearchParams(t2);
307
+ let n2 = this.options.query;
308
+ n2 = n2 instanceof window.FormData ? stringEntriesFromFormData(n2) : n2 instanceof window.URLSearchParams ? n2.entries() : Object.entries(n2 || {});
309
+ mergeEntries(e2, n2);
310
+ const s2 = e2.toString();
311
+ return s2.length > 0 ? `?${s2}` : "";
312
+ }
313
+ get url() {
314
+ return this.originalUrl.split("?")[0].split("#")[0] + this.query;
315
+ }
316
+ get responseKind() {
317
+ return this.options.responseKind || "html";
318
+ }
319
+ get signal() {
320
+ return this.options.signal;
321
+ }
322
+ get redirect() {
323
+ return this.options.redirect || "follow";
324
+ }
325
+ get credentials() {
326
+ return this.options.credentials || "same-origin";
327
+ }
328
+ get additionalHeaders() {
329
+ return this.options.headers || {};
330
+ }
331
+ get formattedBody() {
332
+ const t2 = Object.prototype.toString.call(this.body) === "[object String]";
333
+ const e2 = this.headers["Content-Type"] === "application/json";
334
+ return e2 && !t2 ? JSON.stringify(this.body) : this.body;
335
+ }
336
+ }
337
+
338
+ // app/assets/javascripts/components/web_authn.js
339
+ class WebAuthn extends HTMLElement {
340
+ static observedAttributes = ["action", "callback", "options"];
341
+ constructor() {
342
+ super();
343
+ }
344
+ connectedCallback() {
345
+ this.progressBar = Turbo.navigator.delegate.adapter.progressBar;
346
+ this.style.display = "none";
347
+ this.setAttribute("id", "webauthn");
348
+ this.setAttribute("data-turbo-temporary", 1);
349
+ this.run();
350
+ }
351
+ run() {
352
+ exports__github_webauthn_json[this.action](this.options).then(async (credential) => {
353
+ this.showProgress();
354
+ const { response, redirected } = await post(this.callback, {
355
+ responseKind: "turbo-stream",
356
+ body: JSON.stringify(Object.assign(credential, { webauthn_message: this.message }))
357
+ });
358
+ this.hideProgress();
359
+ if (response.ok && redirected) {
360
+ const responseHTML = await response.text();
361
+ const options = {
362
+ action: "advance",
363
+ shouldCacheSnapshot: false,
364
+ response: { statusCode: response.status, responseHTML, redirected }
365
+ };
366
+ Turbo.navigator.proposeVisit(new URL(response.url), options);
367
+ }
368
+ }).catch((error) => {
369
+ this.onError(error);
370
+ });
371
+ }
372
+ showProgress() {
373
+ this.progressBar.setValue(0);
374
+ this.progressBar.show();
375
+ }
376
+ hideProgress() {
377
+ this.progressBar.setValue(1);
378
+ this.progressBar.hide();
379
+ }
380
+ onError(error) {
381
+ let event;
382
+ if (error.code === 0 && error.name === "NotAllowedError") {
383
+ event = new CustomEvent("web-authn-error", { bubbles: true, detail: "That didn't work. Either it was cancelled or took too long. Please try again." });
384
+ } else if (error.code === 11 && error.name === "InvalidStateError") {
385
+ event = new CustomEvent("web-authn-error", { bubbles: true, detail: "We couldn't add that security key. Looks like you may have already registered it." });
386
+ } else {
387
+ event = new CustomEvent("web-authn-error", { bubbles: true, detail: error.message });
388
+ }
389
+ this.dispatchEvent(event);
390
+ }
391
+ get action() {
392
+ return this.getAttribute("action");
393
+ }
394
+ get callback() {
395
+ return this.getAttribute("callback");
396
+ }
397
+ get options() {
398
+ return JSON.parse(this.getAttribute("options"));
399
+ }
400
+ get message() {
401
+ return this.getAttribute("message");
402
+ }
403
+ }
404
+
405
+ // app/assets/javascripts/components/has_secure_passkey.js
406
+ customElements.define("web-authn", WebAuthn);
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,4 @@
1
+ module HasSecurePasskey
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module HasSecurePasskey
2
+ class ChallengesController < ApplicationController
3
+ def create
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,32 @@
1
+ module HasSecurePasskey
2
+ module ApplicationHelper
3
+ def prompt_for_new_passkey(callback:, current_authenticatable: nil, **options)
4
+ options_for_create =
5
+ if current_authenticatable.present?
6
+ HasSecurePasskey::OptionsForCreate.new(authenticatable: current_authenticatable)
7
+ else
8
+ HasSecurePasskey::OptionsForCreate.
9
+ from_message(params[:webauthn_message])
10
+ end
11
+
12
+ tag.web_authn(nil, action: :create, callback:,
13
+ options: options_for_create.to_json,
14
+ message: options_for_create.message, **options)
15
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
16
+ false
17
+ end
18
+
19
+ def login_with_passkey(callback:, **options)
20
+ options_for_get = HasSecurePasskey::OptionsForGet.new
21
+
22
+ tag.web_authn(nil, action: :get, callback:,
23
+ options: options_for_get.to_json,
24
+ message: options_for_get.message,
25
+ **options)
26
+ end
27
+
28
+ def button_to_passkey_login(text, path, **options)
29
+ button_to text, has_secure_passkey.challenges_path(path), **options
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,6 @@
1
+ module HasSecurePasskey
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module HasSecurePasskey
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ <%= turbo_stream.remove_all "web-authn" %>
2
+
3
+ <%= turbo_stream.append_all "body" do %>
4
+ <%= login_with_passkey(callback: params[:session_path]) %>
5
+ <% end %>