has_secure_passkey 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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 %>