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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +32 -0
- data/Rakefile +8 -0
- data/app/assets/javascripts/@github--webauthn-json.js +1 -0
- data/app/assets/javascripts/@rails--request.js +2 -0
- data/app/assets/javascripts/components/has_secure_passkey.js +3 -0
- data/app/assets/javascripts/components/web_authn.js +81 -0
- data/app/assets/javascripts/has_secure_passkey.js +406 -0
- data/app/assets/stylesheets/has_secure_passkey/application.css +15 -0
- data/app/controllers/has_secure_passkey/application_controller.rb +4 -0
- data/app/controllers/has_secure_passkey/challenges_controller.rb +6 -0
- data/app/helpers/has_secure_passkey/application_helper.rb +32 -0
- data/app/mailers/has_secure_passkey/application_mailer.rb +6 -0
- data/app/models/has_secure_passkey/application_record.rb +5 -0
- data/app/views/has_secure_passkey/challenges/create.turbo_stream.erb +5 -0
- data/app/views/layouts/has_secure_passkey/application.html.erb +17 -0
- data/config/importmap.rb +1 -0
- data/config/routes.rb +3 -0
- data/lib/generators/has_secure_passkey/passkeys/passkeys_generator.rb +59 -0
- data/lib/generators/has_secure_passkey/passkeys/templates/app/channels/application_cable/connection.rb.tt +16 -0
- data/lib/generators/has_secure_passkey/passkeys/templates/app/controllers/concerns/authentication.rb.tt +59 -0
- data/lib/generators/has_secure_passkey/passkeys/templates/app/controllers/email_verifications_controller.rb.tt +7 -0
- data/lib/generators/has_secure_passkey/passkeys/templates/app/controllers/people_controller.rb.tt +11 -0
- data/lib/generators/has_secure_passkey/passkeys/templates/app/controllers/registrations_controller.rb.tt +20 -0
- data/lib/generators/has_secure_passkey/passkeys/templates/app/controllers/sessions_controller.rb.tt +20 -0
- data/lib/generators/has_secure_passkey/passkeys/templates/app/mailers/email_verification_mailer.rb.tt +7 -0
- data/lib/generators/has_secure_passkey/passkeys/templates/app/models/current.rb.tt +4 -0
- data/lib/generators/has_secure_passkey/passkeys/templates/app/models/passkey.rb.tt +30 -0
- data/lib/generators/has_secure_passkey/passkeys/templates/app/models/person.rb.tt +9 -0
- data/lib/generators/has_secure_passkey/passkeys/templates/app/models/session.rb.tt +7 -0
- data/lib/generators/has_secure_passkey/passkeys/templates/app/views/email_verification_mailer/verify.html.erb.tt +1 -0
- data/lib/generators/has_secure_passkey/passkeys/templates/app/views/email_verification_mailer/verify.text.erb.tt +1 -0
- data/lib/generators/has_secure_passkey/passkeys/templates/app/views/email_verifications/show.html.erb.tt +11 -0
- data/lib/generators/has_secure_passkey/passkeys/templates/app/views/registrations/create.turbo_stream.erb.tt +7 -0
- data/lib/generators/has_secure_passkey/passkeys/templates/app/views/registrations/new.html.erb.tt +16 -0
- data/lib/generators/has_secure_passkey/passkeys/templates/test/mailers/previews/email_verification_mailer_preview.rb.tt +7 -0
- data/lib/has_secure_passkey/active_record_helpers.rb +48 -0
- data/lib/has_secure_passkey/add_passkey.rb +45 -0
- data/lib/has_secure_passkey/authenticate_by.rb +49 -0
- data/lib/has_secure_passkey/engine.rb +41 -0
- data/lib/has_secure_passkey/options_for_create.rb +49 -0
- data/lib/has_secure_passkey/options_for_get.rb +34 -0
- data/lib/has_secure_passkey/version.rb +3 -0
- data/lib/has_secure_passkey.rb +12 -0
- data/lib/tasks/has_secure_passkey_tasks.rake +5 -0
- 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 @@
|
|
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,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,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
|