Auto-suggest name for new Passkeys

- Use plain client data json everywhere
- Expose AAGUID and backed_up on Credentials
- Make attestation verifiers configurable
- Auto-suggest names for passkeys and show icons
This commit is contained in:
Stanko K.R.
2026-02-19 10:56:22 +01:00
parent b23660d897
commit fbe9252db1
53 changed files with 666 additions and 172 deletions
+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><g><path d="m13.5 9 .00000017-.00021827c.00112736-1.46023-.532521-2.87033-1.50014-3.96395v-2.53584c0-1.38071-1.11929-2.5-2.5-2.5s-2.5 1.11929-2.5 2.5v.521l-.00000041.00000003c-3.30165.282474-5.74917 3.18798-5.4667 6.48963.200464 2.34309 1.75126 4.35306 3.96684 5.14137v.641l-.854.853.00000005-.00000005c-.0937141.0939506-.146237.221301-.146.354v.00000072c.0003071.132591.0527482.259742.146.354l.854.854v.586l-.854.854.00000003-.00000003c-.195191.19525-.195191.51175-.00000005.707l.854.853v1.292l.00000001-.00000402c-.00023806.132699.052284.26005.145997.354001l1.5 1.5-.00000004-.00000004c.195015.195509.511597.195909.707106.00089385.0002983-.00029755.00059623-.00059548.00089378-.00089378l1.5-1.5.00000001-.00000001c.0937141-.0939496.146238-.221299.146002-.353997v-7.348l-.00000037.00000013c2.39202-.851706 3.99229-3.11289 4-5.652zm-6-3h-.00000004c.552285-.00000002 1 .447715 1 1 .00000002.552285-.447715 1-1 1-.552285.00000002-1-.447715-1-1v.00000011c-.00000008-.552285.447715-1 1-1h.00000004zm2-5h-.00000007c.828427-.00000004 1.5.671573 1.5 1.5v1.63l-.0000001-.00000007c-.602574-.434703-1.28079-.753463-2-.94v1.81h-1v-2.5.00000023c-.00000013-.828427.671573-1.5 1.5-1.5z"/><path d="m22.354 18.026-5.2-5.2.00000012-.00000024c1.31269-2.70353.401635-5.96153-2.123-7.592-.230921-.151427-.540875-.0869843-.692302.143937-.0946713.144371-.108167.327367-.0356979.484063l-.00000011-.00000024c1.57633 3.39991.38992 7.44157-2.774 9.45l.00000003-.00000002c-.232512.148972-.300235.458226-.151263.690738.0917865.143258.250123.23001.420263.230262h.006-.0000002c.879855-.00837008 1.74719-.209417 2.541-.589l.65.577v1.279.00000008c.00000004.276142.223858.5.5.5h1.286l.214.224v1.276.00000008c.00000004.276142.223858.5.5.5h1.119l.917.864c.0928133.0874048.215509.136054.343.136h2.126-.00000002c.276142.00000001.5-.223858.5-.5v-2.121l.00000001.00010623c0-.132352-.052476-.259306-.145925-.353031z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

-1
View File
@@ -1 +0,0 @@
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m12.65 10c-.83-2.33-3.05-4-5.65-4-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4h4.35v4h4v-4h2v-4zm-5.65 4c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/></svg>

Before

Width:  |  Height:  |  Size: 234 B

@@ -0,0 +1,3 @@
<svg width="240" height="240" viewBox="0 0 240 240" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M239.257 120.417C239.257 54.4193 185.755 0.916504 119.757 0.916504C53.7601 0.916504 0.257324 54.4193 0.257324 120.417C0.257324 186.417 53.7601 239.917 119.757 239.917C185.755 239.917 239.257 186.417 239.257 120.417ZM98.0069 54.0276C97.0674 55.8714 97.0674 58.2851 97.0674 63.1126V90.4729C97.0674 91.6788 97.0674 92.2817 97.2196 92.8392C97.3545 93.3331 97.5763 93.799 97.8746 94.215C98.2113 94.6847 98.6792 95.0648 99.6152 95.8251L106.536 101.447C107.664 102.364 108.228 102.822 108.433 103.374C108.613 103.857 108.613 104.39 108.433 104.873C108.228 105.425 107.664 105.883 106.536 106.8L99.6152 112.422C98.6793 113.182 98.2113 113.562 97.8746 114.032C97.5763 114.448 97.3545 114.914 97.2196 115.408C97.0674 115.965 97.0674 116.568 97.0674 117.774V177.719C97.0674 182.547 97.0674 184.961 98.0069 186.805C98.8333 188.426 100.152 189.745 101.774 190.571C103.618 191.511 106.031 191.511 110.859 191.511H128.656C133.483 191.511 135.897 191.511 137.741 190.571C139.363 189.745 140.681 188.426 141.508 186.805C142.447 184.961 142.447 182.547 142.447 177.719V150.359C142.447 149.153 142.447 148.55 142.295 147.993C142.16 147.499 141.938 147.033 141.64 146.617C141.303 146.147 140.835 145.767 139.899 145.007L132.978 139.385C131.85 138.468 131.286 138.01 131.082 137.459C130.902 136.975 130.902 136.443 131.082 135.959C131.286 135.407 131.85 134.949 132.978 134.033L139.899 128.41C140.835 127.65 141.303 127.27 141.64 126.8C141.938 126.384 142.16 125.918 142.295 125.424C142.447 124.867 142.447 124.264 142.447 123.058V63.1126C142.447 58.2851 142.447 55.8714 141.508 54.0276C140.681 52.4057 139.363 51.087 137.741 50.2606C135.897 49.3211 133.483 49.3211 128.656 49.3211H110.859C106.031 49.3211 103.618 49.3211 101.774 50.2606C100.152 51.087 98.8333 52.4057 98.0069 54.0276Z" fill="#FFFEFB"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,3 @@
<svg width="240" height="240" viewBox="0 0 240 240" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M239.116 120.417C239.116 54.4193 185.613 0.916504 119.616 0.916504C53.619 0.916504 0.116211 54.4193 0.116211 120.417C0.116211 186.417 53.619 239.917 119.616 239.917C185.613 239.917 239.116 186.417 239.116 120.417ZM97.8658 54.0276C96.9263 55.8714 96.9263 58.2851 96.9263 63.1126V90.4729C96.9263 91.6788 96.9263 92.2817 97.0785 92.8392C97.2134 93.3331 97.4352 93.799 97.7335 94.215C98.0702 94.6847 98.5381 95.0648 99.4741 95.8251L106.395 101.447C107.523 102.364 108.087 102.822 108.292 103.374C108.471 103.857 108.471 104.39 108.292 104.873C108.087 105.425 107.523 105.883 106.395 106.8L99.4741 112.422C98.5382 113.182 98.0702 113.562 97.7335 114.032C97.4352 114.448 97.2134 114.914 97.0785 115.408C96.9263 115.965 96.9263 116.568 96.9263 117.774V177.719C96.9263 182.547 96.9263 184.961 97.8658 186.805C98.6922 188.426 100.011 189.745 101.633 190.571C103.477 191.511 105.89 191.511 110.718 191.511H128.515C133.342 191.511 135.756 191.511 137.6 190.571C139.221 189.745 140.54 188.426 141.367 186.805C142.306 184.961 142.306 182.547 142.306 177.719V150.359C142.306 149.153 142.306 148.55 142.154 147.993C142.019 147.499 141.797 147.033 141.499 146.617C141.162 146.147 140.694 145.767 139.758 145.007L132.837 139.385C131.709 138.468 131.145 138.01 130.94 137.459C130.761 136.975 130.761 136.443 130.94 135.959C131.145 135.407 131.709 134.949 132.837 134.033L139.758 128.41C140.694 127.65 141.162 127.27 141.499 126.8C141.797 126.384 142.019 125.918 142.154 125.424C142.306 124.867 142.306 124.264 142.306 123.058V63.1126C142.306 58.2851 142.306 55.8714 141.367 54.0276C140.54 52.4057 139.221 51.087 137.6 50.2606C135.756 49.3211 133.342 49.3211 128.515 49.3211H110.718C105.89 49.3211 103.477 49.3211 101.633 50.2606C100.011 51.087 98.6922 52.4057 97.8658 54.0276Z" fill="#1A285F"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,38 @@
<svg viewBox="0 0 322 322" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2">
<path d="M321.347 72.106C321.347 32.31 289.037 0 249.241 0H72.106C32.31 0 0 32.31 0 72.106v177.135c0 39.796 32.31 72.106 72.106 72.106h177.135c39.796 0 72.106-32.31 72.106-72.106V72.106Z" style="fill:#fff"/>
<path d="M126.106 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.821-8.67-39.057-30.754-39.057-56.6 0-33.006 26.478-59.876 59.333-60.511-18.551 13.563-30.61 35.488-30.61 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.006c-.004.137-.006.275-.006.413 0 3.551 1.699 6.535 3.763 9.103Z" style="fill:#ebebeb"/>
<clipPath id="a">
<path d="M126.106 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.821-8.67-39.057-30.754-39.057-56.6 0-33.006 26.478-59.876 59.333-60.511-18.551 13.563-30.61 35.488-30.61 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.006c-.004.137-.006.275-.006.413 0 3.551 1.699 6.535 3.763 9.103Z"/>
</clipPath>
<g clip-path="url(#a)">
<path d="M126.531 277.545V43.603H55.027v233.942h71.504Z" style="fill:url(#b)"/>
</g>
<path d="M167.618 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.82-8.67-39.056-30.754-39.056-56.6 0-33.006 26.477-59.876 59.333-60.511-18.552 13.563-30.611 35.488-30.611 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.007c-.005.137-.007.275-.007.413 0 3.551 1.699 6.535 3.763 9.103Z" style="fill:#ebebeb"/>
<clipPath id="c">
<path d="M167.618 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.82-8.67-39.056-30.754-39.056-56.6 0-33.006 26.477-59.876 59.333-60.511-18.552 13.563-30.611 35.488-30.611 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.007c-.005.137-.007.275-.007.413 0 3.551 1.699 6.535 3.763 9.103Z"/>
</clipPath>
<g clip-path="url(#c)">
<path d="M168.043 277.545V43.603H96.54v233.942h71.503Z" style="fill:url(#d)"/>
</g>
<path d="M177.014 160.725c-22.82-8.67-39.056-30.753-39.056-56.599 0-33.404 27.119-60.523 60.523-60.523 33.403 0 60.523 27.119 60.523 60.523 0 28.22-19.357 51.956-45.506 58.642l26.358 26.862c.994 1.581.943 3.037-.059 4.377l-25.881 26.464 18.777 18.979c1.571 2.132 1.323 4.182-.256 6.171l-31.157 31.03c-1.686 1.207-3.364 1.133-5.008.135l-18.424-19.268c-.453-.648-.763-1.327-.834-2.057v-94.736Zm21.662-93.481c8.924 0 16.169 7.245 16.169 16.169s-7.245 16.169-16.169 16.169-16.169-7.245-16.169-16.169 7.245-16.169 16.169-16.169Z" style="fill:none"/>
<clipPath id="e">
<path d="M177.014 160.725c-22.82-8.67-39.056-30.753-39.056-56.599 0-33.404 27.119-60.523 60.523-60.523 33.403 0 60.523 27.119 60.523 60.523 0 28.22-19.357 51.956-45.506 58.642l26.358 26.862c.994 1.581.943 3.037-.059 4.377l-25.881 26.464 18.777 18.979c1.571 2.132 1.323 4.182-.256 6.171l-31.157 31.03c-1.686 1.207-3.364 1.133-5.008.135l-18.424-19.268c-.453-.648-.763-1.327-.834-2.057v-94.736Zm21.662-93.481c8.924 0 16.169 7.245 16.169 16.169s-7.245 16.169-16.169 16.169-16.169-7.245-16.169-16.169 7.245-16.169 16.169-16.169Z"/>
</clipPath>
<g clip-path="url(#e)">
<path d="M259.004 277.545V43.603H137.958v233.942h121.046Z" style="fill:url(#f)"/>
</g>
<defs>
<linearGradient id="b" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 233.943 -71.5033 0 90.78 43.603)">
<stop offset="0" style="stop-color:#ffdd53;stop-opacity:1"/>
<stop offset="1" style="stop-color:#ffbf01;stop-opacity:1"/>
</linearGradient>
<linearGradient id="d" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 233.943 -71.5033 0 132.291 43.603)">
<stop offset="0" style="stop-color:#51e376;stop-opacity:1"/>
<stop offset="1" style="stop-color:#34c759;stop-opacity:1"/>
</linearGradient>
<linearGradient id="f" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 233.943 -121.046 0 198.481 43.603)">
<stop offset="0" style="stop-color:#62ccf8;stop-opacity:1"/>
<stop offset="1" style="stop-color:#0079ff;stop-opacity:1"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -0,0 +1,38 @@
<svg viewBox="0 0 322 322" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2">
<path d="M321.347 72.106C321.347 32.31 289.037 0 249.241 0H72.106C32.31 0 0 32.31 0 72.106v177.135c0 39.796 32.31 72.106 72.106 72.106h177.135c39.796 0 72.106-32.31 72.106-72.106V72.106Z" style="fill:#fff"/>
<path d="M126.106 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.821-8.67-39.057-30.754-39.057-56.6 0-33.006 26.478-59.876 59.333-60.511-18.551 13.563-30.61 35.488-30.61 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.006c-.004.137-.006.275-.006.413 0 3.551 1.699 6.535 3.763 9.103Z" style="fill:#ebebeb"/>
<clipPath id="a">
<path d="M126.106 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.821-8.67-39.057-30.754-39.057-56.6 0-33.006 26.478-59.876 59.333-60.511-18.551 13.563-30.61 35.488-30.61 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.006c-.004.137-.006.275-.006.413 0 3.551 1.699 6.535 3.763 9.103Z"/>
</clipPath>
<g clip-path="url(#a)">
<path d="M126.531 277.545V43.603H55.027v233.942h71.504Z" style="fill:url(#b)"/>
</g>
<path d="M167.618 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.82-8.67-39.056-30.754-39.056-56.6 0-33.006 26.477-59.876 59.333-60.511-18.552 13.563-30.611 35.488-30.611 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.007c-.005.137-.007.275-.007.413 0 3.551 1.699 6.535 3.763 9.103Z" style="fill:#ebebeb"/>
<clipPath id="c">
<path d="M167.618 266.574c.775.965.309 2.517.017 2.43l-7.773 7.741c-1.686 1.206-3.365 1.132-5.009.135l-18.424-19.268c-.452-.649-.762-1.328-.833-2.058v-94.735c-22.82-8.67-39.056-30.754-39.056-56.6 0-33.006 26.477-59.876 59.333-60.511-18.552 13.563-30.611 35.488-30.611 60.208 0 28.109 15.593 52.604 38.593 65.301v87.841h.007c-.005.137-.007.275-.007.413 0 3.551 1.699 6.535 3.763 9.103Z"/>
</clipPath>
<g clip-path="url(#c)">
<path d="M168.043 277.545V43.603H96.54v233.942h71.503Z" style="fill:url(#d)"/>
</g>
<path d="M177.014 160.725c-22.82-8.67-39.056-30.753-39.056-56.599 0-33.404 27.119-60.523 60.523-60.523 33.403 0 60.523 27.119 60.523 60.523 0 28.22-19.357 51.956-45.506 58.642l26.358 26.862c.994 1.581.943 3.037-.059 4.377l-25.881 26.464 18.777 18.979c1.571 2.132 1.323 4.182-.256 6.171l-31.157 31.03c-1.686 1.207-3.364 1.133-5.008.135l-18.424-19.268c-.453-.648-.763-1.327-.834-2.057v-94.736Zm21.662-93.481c8.924 0 16.169 7.245 16.169 16.169s-7.245 16.169-16.169 16.169-16.169-7.245-16.169-16.169 7.245-16.169 16.169-16.169Z" style="fill:none"/>
<clipPath id="e">
<path d="M177.014 160.725c-22.82-8.67-39.056-30.753-39.056-56.599 0-33.404 27.119-60.523 60.523-60.523 33.403 0 60.523 27.119 60.523 60.523 0 28.22-19.357 51.956-45.506 58.642l26.358 26.862c.994 1.581.943 3.037-.059 4.377l-25.881 26.464 18.777 18.979c1.571 2.132 1.323 4.182-.256 6.171l-31.157 31.03c-1.686 1.207-3.364 1.133-5.008.135l-18.424-19.268c-.453-.648-.763-1.327-.834-2.057v-94.736Zm21.662-93.481c8.924 0 16.169 7.245 16.169 16.169s-7.245 16.169-16.169 16.169-16.169-7.245-16.169-16.169 7.245-16.169 16.169-16.169Z"/>
</clipPath>
<g clip-path="url(#e)">
<path d="M259.004 277.545V43.603H137.958v233.942h121.046Z" style="fill:url(#f)"/>
</g>
<defs>
<linearGradient id="b" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 233.943 -71.5033 0 90.78 43.603)">
<stop offset="0" style="stop-color:#ffdd53;stop-opacity:1"/>
<stop offset="1" style="stop-color:#ffbf01;stop-opacity:1"/>
</linearGradient>
<linearGradient id="d" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 233.943 -71.5033 0 132.291 43.603)">
<stop offset="0" style="stop-color:#51e376;stop-opacity:1"/>
<stop offset="1" style="stop-color:#34c759;stop-opacity:1"/>
</linearGradient>
<linearGradient id="f" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 233.943 -121.046 0 198.481 43.603)">
<stop offset="0" style="stop-color:#62ccf8;stop-opacity:1"/>
<stop offset="1" style="stop-color:#0079ff;stop-opacity:1"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

@@ -0,0 +1,11 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_68_61)">
<path d="M300 253.125C300 279.023 279.023 300 253.125 300H46.875C20.9766 300 0 279.023 0 253.125V46.875C0 20.9766 20.9766 0 46.875 0H253.125C279.023 0 300 20.9766 300 46.875V253.125Z" fill="#175DDC"/>
<path d="M243.105 37.6758C241.201 35.7715 238.945 34.834 236.367 34.834H63.6328C61.0254 34.834 58.7988 35.7715 56.8945 37.6758C54.9902 39.5801 54.0527 41.8359 54.0527 44.4141V159.58C54.0527 168.164 55.7227 176.689 59.0625 185.156C62.4023 193.594 66.5625 201.094 71.5137 207.656C76.4648 214.189 82.3535 220.576 89.209 226.787C96.0645 232.998 102.393 238.125 108.164 242.227C113.965 246.328 120 250.195 126.299 253.857C132.598 257.52 137.08 259.98 139.717 261.27C142.354 262.559 144.492 263.584 146.074 264.258C147.275 264.844 148.564 265.166 149.971 265.166C151.377 265.166 152.666 264.873 153.867 264.258C155.479 263.555 157.588 262.559 160.254 261.27C162.891 259.98 167.373 257.49 173.672 253.857C179.971 250.195 186.006 246.328 191.807 242.227C197.607 238.125 203.936 232.969 210.791 226.787C217.646 220.576 223.535 214.219 228.486 207.656C233.438 201.094 237.568 193.623 240.938 185.156C244.277 176.719 245.947 168.193 245.947 159.58V44.4434C245.977 41.8359 245.01 39.5801 243.105 37.6758ZM220.84 160.664C220.84 202.354 150 238.271 150 238.271V59.502H220.84C220.84 59.502 220.84 118.975 220.84 160.664Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_68_61">
<rect width="300" height="300" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,11 @@
<svg width="300" height="300" viewBox="0 0 300 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_68_61)">
<path d="M300 253.125C300 279.023 279.023 300 253.125 300H46.875C20.9766 300 0 279.023 0 253.125V46.875C0 20.9766 20.9766 0 46.875 0H253.125C279.023 0 300 20.9766 300 46.875V253.125Z" fill="#175DDC"/>
<path d="M243.105 37.6758C241.201 35.7715 238.945 34.834 236.367 34.834H63.6328C61.0254 34.834 58.7988 35.7715 56.8945 37.6758C54.9902 39.5801 54.0527 41.8359 54.0527 44.4141V159.58C54.0527 168.164 55.7227 176.689 59.0625 185.156C62.4023 193.594 66.5625 201.094 71.5137 207.656C76.4648 214.189 82.3535 220.576 89.209 226.787C96.0645 232.998 102.393 238.125 108.164 242.227C113.965 246.328 120 250.195 126.299 253.857C132.598 257.52 137.08 259.98 139.717 261.27C142.354 262.559 144.492 263.584 146.074 264.258C147.275 264.844 148.564 265.166 149.971 265.166C151.377 265.166 152.666 264.873 153.867 264.258C155.479 263.555 157.588 262.559 160.254 261.27C162.891 259.98 167.373 257.49 173.672 253.857C179.971 250.195 186.006 246.328 191.807 242.227C197.607 238.125 203.936 232.969 210.791 226.787C217.646 220.576 223.535 214.219 228.486 207.656C233.438 201.094 237.568 193.623 240.938 185.156C244.277 176.719 245.947 168.193 245.947 159.58V44.4434C245.977 41.8359 245.01 39.5801 243.105 37.6758ZM220.84 160.664C220.84 202.354 150 238.271 150 238.271V59.502H220.84C220.84 59.502 220.84 118.975 220.84 160.664Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_68_61">
<rect width="300" height="300" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,8 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M257.474 359.209C257.474 356.189 254.454 353.169 250.215 351.959L199.411 333.231C190.895 329.601 181.264 333.831 181.264 339.89V475.779C181.264 478.809 184.283 482.438 187.303 483.648L239.326 502.376C247.195 505.396 257.474 501.166 257.474 494.508V359.209Z" fill="white"/>
<path d="M352.736 321.104C352.736 318.084 349.717 315.064 345.477 313.854L294.674 295.126C286.157 291.496 276.526 295.726 276.526 301.785V437.674C276.526 440.704 279.546 444.333 282.566 445.543L334.589 464.271C342.458 467.291 352.736 463.061 352.736 456.403V321.104Z" fill="white"/>
<path d="M257.474 35.3211C257.474 32.3013 254.454 29.2815 250.215 28.0717L199.411 9.34343C190.895 5.71399 181.264 9.94358 181.264 16.0022V151.892C181.264 154.921 184.283 158.551 187.303 159.76L239.326 178.489C247.195 181.509 257.474 177.279 257.474 170.62V35.3211Z" fill="white"/>
<path d="M352.736 92.4777C352.736 89.4579 349.717 86.4382 345.477 85.2283L294.674 66.5C286.157 62.8706 276.526 67.1002 276.526 73.1588V209.048C276.526 212.078 279.546 215.707 282.566 216.917L334.589 235.645C342.458 238.665 352.736 234.436 352.736 227.777V92.4777Z" fill="white"/>
<path d="M448 168.687C448 165.667 444.98 162.647 440.741 161.437L389.937 142.709C381.421 139.079 371.79 143.309 371.79 149.368V361.466C371.79 364.495 374.81 368.125 377.829 369.335L429.852 388.063C437.721 391.083 448 386.853 448 380.194V168.687Z" fill="white"/>
<path d="M162.21 35.3306C162.21 32.3108 159.19 29.2815 154.951 28.0717L104.148 9.34343C95.6787 5.71399 86 9.94358 86 16.0022V475.789C86 478.808 89.0198 482.438 92.0492 483.648L144.063 502.376C151.931 505.396 162.21 501.166 162.21 494.507V35.3306Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,8 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M257.474 359.209C257.474 356.189 254.454 353.169 250.215 351.959L199.411 333.231C190.895 329.601 181.264 333.831 181.264 339.89V475.779C181.264 478.809 184.283 482.438 187.303 483.648L239.326 502.376C247.195 505.396 257.474 501.166 257.474 494.508V359.209Z" fill="#09363F"/>
<path d="M352.736 321.103C352.736 318.084 349.717 315.064 345.477 313.854L294.674 295.126C286.157 291.496 276.526 295.726 276.526 301.785V437.674C276.526 440.704 279.546 444.333 282.566 445.543L334.589 464.271C342.458 467.291 352.736 463.061 352.736 456.403V321.103Z" fill="#09363F"/>
<path d="M257.474 35.3211C257.474 32.3013 254.454 29.2815 250.215 28.0717L199.411 9.34343C190.895 5.71399 181.264 9.94358 181.264 16.0022V151.892C181.264 154.921 184.283 158.551 187.303 159.76L239.326 178.489C247.195 181.508 257.474 177.279 257.474 170.62V35.3211Z" fill="#09363F"/>
<path d="M352.736 92.4777C352.736 89.4579 349.717 86.4382 345.477 85.2283L294.674 66.5C286.157 62.8706 276.526 67.1002 276.526 73.1588V209.048C276.526 212.078 279.546 215.707 282.566 216.917L334.589 235.645C342.458 238.665 352.736 234.436 352.736 227.777V92.4777Z" fill="#09363F"/>
<path d="M448 168.687C448 165.667 444.98 162.647 440.741 161.437L389.937 142.709C381.421 139.079 371.79 143.309 371.79 149.368V361.466C371.79 364.495 374.81 368.125 377.829 369.335L429.852 388.063C437.721 391.083 448 386.853 448 380.194V168.687Z" fill="#09363F"/>
<path d="M162.21 35.3306C162.21 32.3108 159.19 29.2815 154.951 28.0717L104.148 9.34343C95.6787 5.71399 86 9.94358 86 16.0022V475.789C86 478.808 89.0198 482.438 92.0492 483.648L144.063 502.376C151.931 505.396 162.21 501.166 162.21 494.507V35.3306Z" fill="#09363F"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none"><path d="M160 16a64 64 0 0 0-60.3 86.4L36 166.1V224h48v-32h32v-32h32l15.7-15.7A64 64 0 1 0 160 16Zm16 72a24 24 0 1 1 0-48 24 24 0 0 1 0 48Z" fill="#999"/></svg>

After

Width:  |  Height:  |  Size: 234 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none"><path d="M160 16a64 64 0 0 0-60.3 86.4L36 166.1V224h48v-32h32v-32h32l15.7-15.7A64 64 0 1 0 160 16Zm16 72a24 24 0 1 1 0-48 24 24 0 0 1 0 48Z" fill="#888"/></svg>

After

Width:  |  Height:  |  Size: 234 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 192 192" height="24px" viewBox="0 0 192 192" width="24px"><rect fill="none" height="192" width="192" y="0"/><g><path d="M69.29,106c-3.46,5.97-9.91,10-17.29,10c-11.03,0-20-8.97-20-20s8.97-20,20-20 c7.38,0,13.83,4.03,17.29,10h25.55C90.3,66.54,72.82,52,52,52C27.74,52,8,71.74,8,96s19.74,44,44,44c20.82,0,38.3-14.54,42.84-34 H69.29z" fill="#4285F4"/><rect fill="#FBBC04" height="24" width="44" x="94" y="84"/><path d="M94.32,84H68v0.05c2.5,3.34,4,7.47,4,11.95s-1.5,8.61-4,11.95V108h26.32 c1.08-3.82,1.68-7.84,1.68-12S95.41,87.82,94.32,84z" fill="#EA4335"/><path d="M184,106v26h-16v-8c0-4.42-3.58-8-8-8s-8,3.58-8,8v8h-16v-26H184z" fill="#34A853"/><rect fill="#188038" height="24" width="48" x="136" y="84"/></g></svg>

After

Width:  |  Height:  |  Size: 779 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 192 192" height="24px" viewBox="0 0 192 192" width="24px"><rect fill="none" height="192" width="192" y="0"/><g><path d="M69.29,106c-3.46,5.97-9.91,10-17.29,10c-11.03,0-20-8.97-20-20s8.97-20,20-20 c7.38,0,13.83,4.03,17.29,10h25.55C90.3,66.54,72.82,52,52,52C27.74,52,8,71.74,8,96s19.74,44,44,44c20.82,0,38.3-14.54,42.84-34 H69.29z" fill="#4285F4"/><rect fill="#FBBC04" height="24" width="44" x="94" y="84"/><path d="M94.32,84H68v0.05c2.5,3.34,4,7.47,4,11.95s-1.5,8.61-4,11.95V108h26.32 c1.08-3.82,1.68-7.84,1.68-12S95.41,87.82,94.32,84z" fill="#EA4335"/><path d="M184,106v26h-16v-8c0-4.42-3.58-8-8-8s-8,3.58-8,8v8h-16v-26H184z" fill="#34A853"/><rect fill="#188038" height="24" width="48" x="136" y="84"/></g></svg>

After

Width:  |  Height:  |  Size: 779 B

@@ -0,0 +1,13 @@
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="50" height="50" rx="7.84782" fill="url(#paint0_linear_284_5743)"/>
<path d="M39.3518 17.6383C38.905 17.6383 38.5668 17.9747 38.5668 18.4287V31.5604C38.5668 32.0108 38.9014 32.3509 39.3518 32.3509C39.8022 32.3509 40.1368 32.0144 40.1368 31.5604V18.4287C40.1368 17.9784 39.8022 17.6383 39.3518 17.6383Z" fill="white"/>
<path d="M21.8918 22.5449C20.3814 22.5449 19.0954 23.7857 19.0954 25.3069C19.0954 26.8281 20.3272 28.1232 21.8375 28.1232C23.3478 28.1232 24.6339 26.8824 24.6339 25.3612C24.6339 23.84 23.4021 22.5449 21.8918 22.5449Z" fill="white"/>
<path d="M31.0694 22.5449C29.5591 22.5449 28.2731 23.7857 28.2731 25.3069C28.2731 26.8281 29.5048 28.1232 31.0152 28.1232C32.5255 28.1232 33.8115 26.8824 33.8115 25.3612C33.8658 23.84 32.634 22.5449 31.0694 22.5449Z" fill="white"/>
<path d="M12.6596 22.5449C11.1493 22.5449 9.86328 23.7857 9.86328 25.3069C9.86328 26.8281 11.0951 28.1232 12.6054 28.1232C14.1157 28.1232 15.4017 26.8824 15.4017 25.3612C15.4017 23.84 14.17 22.5449 12.6596 22.5449Z" fill="white"/>
<defs>
<linearGradient id="paint0_linear_284_5743" x1="25" y1="0" x2="25" y2="50" gradientUnits="userSpaceOnUse">
<stop stop-color="#3C3D3D"/>
<stop offset="1" stop-color="#262626"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,13 @@
<svg width="50" height="50" viewBox="0 0 50 50" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="50" height="50" rx="7.84782" fill="url(#paint0_linear_284_5774)"/>
<path d="M39.3517 17.6384C38.9049 17.6384 38.5667 17.9749 38.5667 18.4289V31.5605C38.5667 32.0108 38.9013 32.3509 39.3517 32.3509C39.8021 32.3509 40.1367 32.0145 40.1367 31.5605V18.4289C40.1367 17.9785 39.8021 17.6384 39.3517 17.6384Z" fill="white"/>
<path d="M21.8918 22.545C20.3814 22.545 19.0954 23.7859 19.0954 25.307C19.0954 26.8282 20.3272 28.1233 21.8375 28.1233C23.3478 28.1233 24.6338 26.8825 24.6338 25.3613C24.6338 23.8401 23.4021 22.545 21.8918 22.545Z" fill="white"/>
<path d="M31.0694 22.545C29.5591 22.545 28.273 23.7859 28.273 25.307C28.273 26.8282 29.5048 28.1233 31.0151 28.1233C32.5254 28.1233 33.8115 26.8825 33.8115 25.3613C33.8657 23.8401 32.634 22.545 31.0694 22.545Z" fill="white"/>
<path d="M12.6597 22.545C11.1494 22.545 9.86339 23.7859 9.86339 25.307C9.86339 26.8282 11.0952 28.1233 12.6055 28.1233C14.1158 28.1233 15.4018 26.8825 15.4018 25.3613C15.4018 23.8401 14.17 22.545 12.6597 22.545Z" fill="white"/>
<defs>
<linearGradient id="paint0_linear_284_5774" x1="25" y1="0" x2="25" y2="50" gradientUnits="userSpaceOnUse">
<stop stop-color="#ED194A"/>
<stop offset="1" stop-color="#C30833"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><defs><style>.cls-1{fill:#0078d4;stroke-width:0px;}</style></defs><rect class="cls-1" x="24.25" y="24.25" width="98.35" height="98.35"/><rect class="cls-1" x="133.4" y="24.25" width="98.35" height="98.35"/><rect class="cls-1" x="24.25" y="133.4" width="98.35" height="98.35"/><rect class="cls-1" x="133.4" y="133.4" width="98.35" height="98.35"/></svg>

After

Width:  |  Height:  |  Size: 427 B

@@ -0,0 +1 @@
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><defs><style>.cls-1{fill:#0078d4;stroke-width:0px;}</style></defs><rect class="cls-1" x="24.25" y="24.25" width="98.35" height="98.35"/><rect class="cls-1" x="133.4" y="24.25" width="98.35" height="98.35"/><rect class="cls-1" x="24.25" y="133.4" width="98.35" height="98.35"/><rect class="cls-1" x="133.4" y="133.4" width="98.35" height="98.35"/></svg>

After

Width:  |  Height:  |  Size: 427 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none"><circle cx="128" cy="128" r="120" fill="#9aca3c"/><path d="M128,160 L88,88 L108,88 L128,128 L148,88 L168,88 L140,148 L140,184 L116,184 L116,148 Z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 240 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="none"><circle cx="128" cy="128" r="120" fill="#9aca3c"/><path d="M128,160 L88,88 L108,88 L128,128 L148,88 L168,88 L140,148 L140,184 L116,184 L116,148 Z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 240 B

+29
View File
@@ -0,0 +1,29 @@
@layer components {
.passkey-icon--dark {
display: none;
}
.passkey-icon--light {
html[data-theme="dark"] & {
display: none;
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) & {
display: none;
}
}
}
.passkey-icon--dark {
html[data-theme="dark"] & {
display: inline;
}
@media (prefers-color-scheme: dark) {
html:not([data-theme]) & {
display: inline;
}
}
}
}
+1 -1
View File
@@ -27,6 +27,7 @@
.icon--art { --svg: url("art.svg "); } .icon--art { --svg: url("art.svg "); }
.icon--assigned { --svg: url("assigned.svg "); } .icon--assigned { --svg: url("assigned.svg "); }
.icon--attachment { --svg: url("attachment.svg "); } .icon--attachment { --svg: url("attachment.svg "); }
.icon--authentication { --svg: url("authentication.svg "); }
.icon--bell-alert { --svg: url("bell-alert.svg "); } .icon--bell-alert { --svg: url("bell-alert.svg "); }
.icon--bell-off { --svg: url("bell-off.svg "); } .icon--bell-off { --svg: url("bell-off.svg "); }
.icon--bell { --svg: url("bell.svg "); } .icon--bell { --svg: url("bell.svg "); }
@@ -62,7 +63,6 @@
.icon--history { --svg: url("history.svg "); } .icon--history { --svg: url("history.svg "); }
.icon--home { --svg: url("home.svg "); } .icon--home { --svg: url("home.svg "); }
.icon--install-edge { --svg: url("install-edge.svg "); } .icon--install-edge { --svg: url("install-edge.svg "); }
.icon--key { --svg: url("key.svg "); }
.icon--lifebuoy { --svg: url("lifebuoy.svg "); } .icon--lifebuoy { --svg: url("lifebuoy.svg "); }
.icon--lock { --svg: url("lock.svg "); } .icon--lock { --svg: url("lock.svg "); }
.icon--logout { --svg: url("logout.svg "); } .icon--logout { --svg: url("logout.svg "); }
@@ -10,6 +10,7 @@ module CurrentRequest
Current.referrer = request.referrer Current.referrer = request.referrer
ActionPack::WebAuthn::Current.host = request.host ActionPack::WebAuthn::Current.host = request.host
ActionPack::WebAuthn::Current.origin = request.base_url
end end
end end
end end
@@ -5,12 +5,8 @@ class Sessions::PasskeysController < ApplicationController
def create def create
credential = Identity::Credential.authenticate( credential = Identity::Credential.authenticate(
id: params.expect(:credential_id), passkey: passkey_params,
client_data_json: response_params[:client_data_json], challenge: session.delete(:webauthn_challenge)
authenticator_data: response_params[:authenticator_data],
signature: response_params[:signature],
challenge: session.delete(:webauthn_challenge),
origin: request.base_url
) )
if credential if credential
@@ -21,8 +17,8 @@ class Sessions::PasskeysController < ApplicationController
end end
private private
def response_params def passkey_params
params.expect(response: [ :client_data_json, :authenticator_data, :signature ]) params.expect(passkey: [ :id, :client_data_json, :authenticator_data, :signature ])
end end
def authentication_succeeded(identity) def authentication_succeeded(identity)
+19 -13
View File
@@ -1,26 +1,28 @@
class Users::CredentialsController < ApplicationController class Users::CredentialsController < ApplicationController
before_action :set_credential, only: :destroy before_action :set_credential, only: %i[ edit update destroy ]
def index def index
@credentials = Current.identity.credentials.order(name: :asc, created_at: :desc) @credentials = Current.identity.credentials.order(name: :asc, created_at: :desc)
end
def new
@creation_options = Identity::Credential.creation_options(identity: Current.identity, display_name: Current.user.name) @creation_options = Identity::Credential.creation_options(identity: Current.identity, display_name: Current.user.name)
session[:webauthn_challenge] = @creation_options.challenge session[:webauthn_challenge] = @creation_options.challenge
end end
def create def create
Identity::Credential.register( credential = Current.identity.credentials.register(
identity: Current.identity, passkey: passkey_params,
name: credential_params[:name], challenge: session.delete(:webauthn_challenge)
client_data_json: credential_params[:client_data_json],
attestation_object: credential_params[:attestation_object],
challenge: session.delete(:webauthn_challenge),
origin: request.base_url,
transports: Array(credential_params[:transports])
) )
render json: { location: edit_user_credential_path(Current.user, credential, created: true) }
rescue ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError => error
render json: { error: error.message }, status: :unprocessable_entity
end
def edit
end
def update
@credential.update!(credential_params)
redirect_to user_credentials_path(Current.user) redirect_to user_credentials_path(Current.user)
end end
@@ -35,6 +37,10 @@ class Users::CredentialsController < ApplicationController
end end
def credential_params def credential_params
params.expect(credential: [ :name, :client_data_json, :attestation_object, transports: [] ]) params.expect(credential: [ :name ])
end
def passkey_params
params.expect(passkey: [ :client_data_json, :attestation_object, transports: [] ])
end end
end end
@@ -1,60 +1,60 @@
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus"
import { post } from "@rails/request.js"
import { base64urlToBuffer, bufferToBase64url } from "helpers/base64url_helpers"
export default class extends Controller { export default class extends Controller {
static values = { publicKey: Object } static values = { publicKey: Object, registerUrl: String }
static targets = ["clientDataJSON", "attestationObject"] static targets = ["button", "error", "cancelled"]
async create(event) { async create() {
event.preventDefault() this.buttonTarget.disabled = true
this.errorTarget.hidden = true
this.cancelledTarget.hidden = true
try { try {
const publicKey = this.prepareOptions(this.publicKeyValue) const publicKey = this.#prepareOptions(this.publicKeyValue)
const credential = await navigator.credentials.create({ publicKey }) const credential = await navigator.credentials.create({ publicKey })
this.submitCredential(credential) await this.#registerCredential(credential)
} catch (error) { } catch (error) {
if (error.name !== "AbortError" && error.name !== "NotAllowedError") { if (error.name === "AbortError" || error.name === "NotAllowedError") {
console.error("Registration failed:", error) this.cancelledTarget.hidden = false
} else {
this.errorTarget.hidden = false
} }
this.buttonTarget.disabled = false
} }
} }
submitCredential(credential) { async #registerCredential(credential) {
this.clientDataJSONTarget.value = this.bufferToBase64url(credential.response.clientDataJSON) const response = await post(this.registerUrlValue, {
this.attestationObjectTarget.value = this.bufferToBase64url(credential.response.attestationObject) body: JSON.stringify({
passkey: {
client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),
attestation_object: bufferToBase64url(credential.response.attestationObject),
transports: credential.response.getTransports?.() || []
}
}),
contentType: "application/json",
responseKind: "json"
})
for (const transport of credential.response.getTransports?.() || []) { if (response.ok) {
const input = document.createElement("input") const { location } = await response.json
input.type = "hidden" Turbo.visit(location)
input.name = "credential[transports][]" } else {
input.value = transport throw new Error("Registration failed")
this.element.appendChild(input) }
} }
this.element.requestSubmit() #prepareOptions(options) {
}
prepareOptions(options) {
return { return {
...options, ...options,
challenge: this.base64urlToBuffer(options.challenge), challenge: base64urlToBuffer(options.challenge),
user: { ...options.user, id: this.base64urlToBuffer(options.user.id) }, user: { ...options.user, id: base64urlToBuffer(options.user.id) },
excludeCredentials: (options.excludeCredentials || []).map(cred => ({ excludeCredentials: (options.excludeCredentials || []).map(cred => ({
...cred, ...cred,
id: this.base64urlToBuffer(cred.id) id: base64urlToBuffer(cred.id)
})) }))
} }
} }
base64urlToBuffer(base64url) {
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/")
const padding = "=".repeat((4 - base64.length % 4) % 4)
const binary = atob(base64 + padding)
return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer
}
bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer)
const binary = String.fromCharCode(...bytes)
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
} }
@@ -1,4 +1,5 @@
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus"
import { base64urlToBuffer, bufferToBase64url } from "helpers/base64url_helpers"
export default class extends Controller { export default class extends Controller {
static values = { publicKey: Object, url: String, csrfToken: String } static values = { publicKey: Object, url: String, csrfToken: String }
@@ -41,10 +42,10 @@ export default class extends Controller {
const fields = { const fields = {
authenticity_token: this.csrfTokenValue, authenticity_token: this.csrfTokenValue,
credential_id: credential.id, "passkey[id]": credential.id,
"response[client_data_json]": new TextDecoder().decode(credential.response.clientDataJSON), "passkey[client_data_json]": new TextDecoder().decode(credential.response.clientDataJSON),
"response[authenticator_data]": this.#bufferToBase64url(credential.response.authenticatorData), "passkey[authenticator_data]": bufferToBase64url(credential.response.authenticatorData),
"response[signature]": this.#bufferToBase64url(credential.response.signature) "passkey[signature]": bufferToBase64url(credential.response.signature)
} }
for (const [name, value] of Object.entries(fields)) { for (const [name, value] of Object.entries(fields)) {
@@ -62,13 +63,13 @@ export default class extends Controller {
#prepareOptions(options) { #prepareOptions(options) {
const prepared = { const prepared = {
...options, ...options,
challenge: this.#base64urlToBuffer(options.challenge) challenge: base64urlToBuffer(options.challenge)
} }
if (options.allowCredentials?.length) { if (options.allowCredentials?.length) {
prepared.allowCredentials = options.allowCredentials.map(cred => ({ prepared.allowCredentials = options.allowCredentials.map(cred => ({
...cred, ...cred,
id: this.#base64urlToBuffer(cred.id) id: base64urlToBuffer(cred.id)
})) }))
} else { } else {
delete prepared.allowCredentials delete prepared.allowCredentials
@@ -76,17 +77,4 @@ export default class extends Controller {
return prepared return prepared
} }
#base64urlToBuffer(base64url) {
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/")
const padding = "=".repeat((4 - base64.length % 4) % 4)
const binary = atob(base64 + padding)
return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer
}
#bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer)
const binary = String.fromCharCode(...bytes)
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
} }
@@ -0,0 +1,12 @@
export function base64urlToBuffer(base64url) {
const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/")
const padding = "=".repeat((4 - base64.length % 4) % 4)
const binary = atob(base64 + padding)
return Uint8Array.from(binary, c => c.charCodeAt(0)).buffer
}
export function bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer)
const binary = String.fromCharCode(...bytes)
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
+22 -15
View File
@@ -18,44 +18,51 @@ class Identity::Credential < ApplicationRecord
ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(credentials: credentials.map(&:to_public_key_credential)) ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(credentials: credentials.map(&:to_public_key_credential))
end end
def authenticate(id:, **params) def register(passkey:, challenge:, origin: ActionPack::WebAuthn::Current.origin, **attributes)
find_by(credential_id: id)&.authenticate(**params)
end
def register(identity:, name:, client_data_json:, attestation_object:, challenge:, origin:, transports: [])
public_key_credential = ActionPack::WebAuthn::PublicKeyCredential.create( public_key_credential = ActionPack::WebAuthn::PublicKeyCredential.create(
client_data_json: Base64.urlsafe_decode64(client_data_json), client_data_json: passkey[:client_data_json],
attestation_object: Base64.urlsafe_decode64(attestation_object), attestation_object: Base64.urlsafe_decode64(passkey[:attestation_object]),
challenge: challenge, challenge: challenge,
origin: origin, origin: origin,
transports: transports transports: Array(passkey[:transports])
) )
identity.credentials.create!( create!(
name: name, **attributes,
name: attributes.fetch(:name, Authenticator.find_by_aaguid(public_key_credential.aaguid)&.name),
credential_id: public_key_credential.id, credential_id: public_key_credential.id,
public_key: public_key_credential.public_key.to_der, public_key: public_key_credential.public_key.to_der,
sign_count: public_key_credential.sign_count, sign_count: public_key_credential.sign_count,
aaguid: public_key_credential.aaguid,
backed_up: public_key_credential.backed_up,
transports: public_key_credential.transports transports: public_key_credential.transports
) )
end end
def authenticate(passkey:, challenge:, origin: ActionPack::WebAuthn::Current.origin)
find_by(credential_id: passkey[:id])&.authenticate(passkey: passkey, challenge: challenge, origin: origin)
end
end end
def authenticate(client_data_json:, authenticator_data:, signature:, challenge:, origin:) def authenticate(passkey:, challenge:, origin: ActionPack::WebAuthn::Current.origin)
pkc = to_public_key_credential pkc = to_public_key_credential
pkc.authenticate( pkc.authenticate(
client_data_json: client_data_json, client_data_json: passkey[:client_data_json],
authenticator_data: Base64.urlsafe_decode64(authenticator_data), authenticator_data: Base64.urlsafe_decode64(passkey[:authenticator_data]),
signature: Base64.urlsafe_decode64(signature), signature: Base64.urlsafe_decode64(passkey[:signature]),
challenge: challenge, challenge: challenge,
origin: origin origin: origin
) )
update!(sign_count: pkc.sign_count) update!(sign_count: pkc.sign_count, backed_up: pkc.backed_up)
self self
rescue ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError rescue ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError
nil nil
end end
def authenticator
Authenticator.find_by_aaguid(aaguid)
end
def to_public_key_credential def to_public_key_credential
ActionPack::WebAuthn::PublicKeyCredential.new( ActionPack::WebAuthn::PublicKeyCredential.new(
id: credential_id, id: credential_id,
@@ -0,0 +1,12 @@
class Identity::Credential::Authenticator < Data.define(:name, :icon)
REGISTRY = Rails.application.config_for(:passkey_aaguids).each_with_object({}) do |(_key, attrs), hash|
authenticator = new(name: attrs[:name], icon: attrs[:icon])
attrs[:aaguids].each { |aaguid| hash[aaguid] = authenticator }
end.freeze
class << self
def find_by_aaguid(aaguid)
REGISTRY[aaguid]
end
end
end
@@ -1,11 +1,10 @@
<tr style="view-transition-name: <%= dom_id(credential) %>"> <li class="list-style-none margin-none" style="view-transition-name: <%= dom_id(credential) %>">
<td><strong><%= credential.name.presence || "Passkey" %></strong></td> <%= link_to edit_user_credential_path(Current.user, credential), class: "txt-ink flex align-center gap txt-medium full-width" do %>
<td> <% authenticator = credential.authenticator %>
<%= button_to user_credential_path(Current.user, credential), method: :delete, <%= image_tag authenticator&.icon&.[](:light) || "passkeys/generic_light.svg", size: 24, class: "flex-item-no-shrink passkey-icon passkey-icon--light", aria: { hidden: true } %>
class: "btn btn--circle txt-negative txt-xx-small borderless", <%= image_tag authenticator&.icon&.[](:dark) || "passkeys/generic_dark.svg", size: 24, class: "flex-item-no-shrink passkey-icon passkey-icon--dark", aria: { hidden: true } %>
data: { turbo_confirm: "Are you sure you want to remove this passkey?" } do %> <strong class="overflow-ellipsis min-width"><%= credential.name.presence || "Passkey" %></strong>
<%= icon_tag "trash" %> <hr class="separator--horizontal flex-item-grow" style="--border-color: var(--color-ink-medium); --border-style: dashed" aria-hidden="true">
<span class="for-screen-reader">Remove this passkey</span> <%= icon_tag "arrow-right", class: "txt-subtle txt-xx-small flex-item-no-shrink" %>
<% end %> <% end %>
</td> </li>
</tr>
+33
View File
@@ -0,0 +1,33 @@
<% @page_title = "Name your passkey" %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "Passkeys", user_credentials_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
<% end %>
<section class="panel panel--wide shadow center txt-align-start flex flex-column gap">
<% if params[:created] %>
<p class="margin-none">Your passkey has been registered. Give it a name so you can identify it later.</p>
<% end %>
<%= form_with model: @credential, scope: :credential, url: user_credential_path(Current.user, @credential), html: { class: "flex flex-column gap" } do |form| %>
<div class="flex flex-column gap-half">
<strong><%= form.label :name %></strong>
<%= form.text_field :name, autofocus: true, class: "input", placeholder: "e.g. MacBook Pro, iPhone", data: { "1p-ignore": "" }, autocomplete: "off" %>
</div>
<%= form.submit "Save", class: "btn btn--primary" %>
<% end %>
<div class="txt-align-center">
<%= button_to user_credential_path(Current.user, @credential), method: :delete,
class: "btn txt-negative borderless txt-small",
data: { turbo_confirm: "Are you sure you want to remove this passkey?" } do %>
<%= icon_tag "trash" %>
<span>Remove this passkey</span>
<% end %>
</div>
</section>
+23 -15
View File
@@ -8,25 +8,33 @@
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1> <h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
<% end %> <% end %>
<section class="panel panel--wide shadow center txt-align-start"> <section class="panel panel--wide shadow center"
<p class="margin-none-block-start">Passkeys let you sign in using your devices biometrics, PIN, or security key&mdash;no email code needed.</p> data-controller="credential"
data-credential-public-key-value="<%= @creation_options.as_json.to_json %>"
data-credential-register-url-value="<%= user_credentials_path(Current.user) %>">
<p class="margin-none-block-start"><strong>Passkeys let you sign in securely without a password or email code.</strong></p>
<% if @credentials.any? %> <% if @credentials.any? %>
<table class="access-tokens"> <ul class="flex flex-column gap margin-none-block-start unpad full-width">
<thead>
<tr>
<th>Passkey</th>
<th></th>
</tr>
</thead>
<tbody>
<%= render partial: "users/credentials/credential", collection: @credentials %> <%= render partial: "users/credentials/credential", collection: @credentials %>
</tbody> </ul>
</table>
<% end %> <% end %>
<%= link_to new_user_credential_path(Current.user), class: "btn btn--link" do %> <p class="txt-small pad-inline-double pad-block-half">Your browser will prompt you to create a passkey
using your device's biometrics, PIN, or security key</p>
<button type="button" data-action="credential#create" data-credential-target="button" class="btn btn--link center txt-medium">
<%= icon_tag "add" %> <%= icon_tag "add" %>
<span>Add a passkey</span> <span>Register passkey</span>
<% end %> </button>
<p data-credential-target="error" class="txt-negative" hidden>
Something went wrong while registering your passkey.
</p>
<p data-credential-target="cancelled" class="txt-subtle" hidden>
Passkey registration was cancelled.
Try again when you are ready.
</p>
</section> </section>
-31
View File
@@ -1,31 +0,0 @@
<% @page_title = "Add a passkey" %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "Passkeys", user_credentials_path(Current.user), "keydown.left@document->hotkey#click keydown.esc@document->hotkey#click" %>
</div>
<h1 class="header__title" data-bridge--title-target="header"><%= @page_title %></h1>
<% end %>
<section class="panel panel--wide shadow center txt-align-start">
<%= form_with url: user_credentials_path(Current.user),
data: { controller: "credential", credential_public_key_value: @creation_options.as_json },
html: { class: "flex flex-column gap" } do |form| %>
<div class="flex flex-column gap-half">
<strong><label for="credential_name">Passkey name</label></strong>
<input type="text" id="credential_name" name="credential[name]" required autofocus class="input" placeholder="e.g. MacBook Pro, iPhone">
</div>
<input type="hidden" name="credential[client_data_json]" data-credential-target="clientDataJSON">
<input type="hidden" name="credential[attestation_object]" data-credential-target="attestationObject">
<button type="button" data-action="credential#create" class="btn btn--link center txt-medium">
<%= icon_tag "add" %>
<span>Register passkey</span>
</button>
<% end %>
<%= link_to "Cancel and go back", user_credentials_path(Current.user), hidden: true %>
</section>
+1 -1
View File
@@ -6,7 +6,7 @@
<div class="flex flex-column gap position-relative"> <div class="flex flex-column gap position-relative">
<% if Current.user == @user %> <% if Current.user == @user %>
<%= link_to user_credentials_path(@user), class: "user-edit-link btn", style: "inset: 0 auto auto 0", data: { controller: "tooltip" } do %> <%= link_to user_credentials_path(@user), class: "user-edit-link btn", style: "inset: 0 auto auto 0", data: { controller: "tooltip" } do %>
<%= icon_tag "key" %> <%= icon_tag "authentication" %>
<span class="for-screen-reader">Manage passkeys</span> <span class="for-screen-reader">Manage passkeys</span>
<% end %> <% end %>
+124
View File
@@ -0,0 +1,124 @@
shared:
apple_passwords:
name: Apple Passwords
icon:
light: passkeys/apple_passwords_light.svg
dark: passkeys/apple_passwords_dark.svg
aaguids:
- dd4ec289-e01d-41c9-bb89-70fa845d4bf2
- fbfc3007-154e-4ecc-8c0b-6e020557d7bd
google_password_manager:
name: Google Password Manager
icon:
light: passkeys/google_password_manager_light.svg
dark: passkeys/google_password_manager_dark.svg
aaguids:
- ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4
windows_hello:
name: Windows Hello
icon:
light: passkeys/windows_hello_light.svg
dark: passkeys/windows_hello_dark.svg
aaguids:
- 08987058-cadc-4b81-b6e1-30de50dcbe96
- 6028b017-b1d4-4c02-b4b3-afcdafc96bb2
- 9ddd1817-af5a-4672-a2b9-3e3dd95000a9
1password:
name: 1Password
icon:
light: passkeys/1password_light.svg
dark: passkeys/1password_dark.svg
aaguids:
- bada5566-a7aa-401f-bd96-45619a55120d
bitwarden:
name: Bitwarden
icon:
light: passkeys/bitwarden_light.svg
dark: passkeys/bitwarden_dark.svg
aaguids:
- d548826e-79b4-db40-a3d8-11116f7e8349
samsung_pass:
name: Samsung Pass
icon:
light: passkeys/samsung_pass_light.svg
dark: passkeys/samsung_pass_dark.svg
aaguids:
- 53414d53-554e-4700-0000-000000000000
lastpass:
name: LastPass
icon:
light: passkeys/lastpass_light.svg
dark: passkeys/lastpass_dark.svg
aaguids:
- b78a0a55-6ef8-d246-a042-ba0f6d55050c
dashlane:
name: Dashlane
icon:
light: passkeys/dashlane_light.svg
dark: passkeys/dashlane_dark.svg
aaguids:
- 531126d6-e717-415c-9320-3d9aa6981239
yubikey:
name: YubiKey
icon:
light: passkeys/yubikey_light.svg
dark: passkeys/yubikey_dark.svg
aaguids:
- 19083c3d-8383-4b18-bc03-8f1c9ab2fd1b
- 1ac71f64-468d-4fe0-bef1-0e5f2f551f18
- 20ac7a17-c814-4833-93fe-539f0d5e3389
- 24673149-6c86-42e7-98d9-433fb5b73296
- 2fc0579f-8113-47ea-b116-bb5a8db9202a
- 3124e301-f14e-4e38-876d-fbeeb090e7bf
- 34744913-4f57-4e6e-a527-e9ec3c4b94e6
- 34f5766d-1536-4a24-9033-0e294e510fb0
- 3a662962-c6d4-4023-bebb-98ae92e78e20
- 3aa78eb1-ddd8-46a8-a821-8f8ec57a7bd5
- 3b24bf49-1d45-4484-a917-13175df0867b
- 4599062e-6926-4fe7-9566-9e8fb1aedaa0
- 4fc84f16-2545-4e53-b8fc-7bf4d7282a10
- 57f7de54-c807-4eab-b1c6-1c9be7984e92
- 58276709-bb4b-4bb3-baf1-60eea99282a7
- 5b0e46ba-db02-44ac-b979-ca9b84f5e335
- 62e54e98-c209-4df3-b692-de71bb6a8528
- 662ef48a-95e2-4aaa-a6c1-5b9c40375824
- 6ab56fad-881f-4a43-acb2-0be065924522
- 6ec5cff2-a0f9-4169-945b-f33b563f7b99
- 73bb0cd4-e502-49b8-9c6f-b59445bf720b
- 7409272d-1ff9-4e10-9fc9-ac0019c124fd
- 79f3c8ba-9e35-484b-8f47-53a5a0f5c630
- 7b96457d-e3cd-432b-9ceb-c9fdd7ef7432
- 7d1351a6-e097-4852-b8bf-c9ac5c9ce4a3
- 83c47309-aabb-4108-8470-8be838b573cb
- 85203421-48f9-4355-9bc8-8a53846e5083
- 8c39ee86-7f9a-4a95-9ba3-f6b097e5c2ee
- 905b4cb4-ed6f-4da9-92fc-45e0d4e9b5c7
- 90636e1f-ef82-43bf-bdcf-5255f139d12f
- 97e6a830-c952-4740-95fc-7c78dc97ce47
- 9e66c661-e428-452a-a8fb-51f7ed088acf
- 9eb7eabc-9db5-49a1-b6c3-555a802093f4
- a02167b9-ae71-4ac7-9a07-06432ebb6f1c
- a25342c0-3cdc-4414-8e46-f4807fca511c
- ad08c78a-4e41-49b9-86a2-ac15b06899e2
- b2c1a50b-dad8-4dc7-ba4d-0ce9597904bc
- b90e7dc1-316e-4fee-a25a-56a666a670fe
- c1f9a0bc-1dd2-404a-b27f-8e29047a43fd
- c5ef55ff-ad9a-4b9f-b580-adebafe026d0
- cb69481e-8ff7-4039-93ec-0a2729a154a8
- ce6bf97f-9f69-4ba7-9032-97adc6ca5cf1
- d2fbd093-ee62-488d-9dad-1e36389f8826
- d7781e5d-e353-46aa-afe2-3ca49f13332a
- d8522d9f-575b-4866-88a9-ba99fa02f35b
- dd86a2da-86a0-4cbe-b462-4bd31f57bc6f
- ee882879-721c-4913-9775-3dfcce97072a
- fa2b99dc-9e39-4257-8f92-4a30d23c4118
- fcc0118f-cd45-435b-8da1-9782b2da0715
- ff4dac45-ede8-4ec2-aced-cf66103f4335
+1 -1
View File
@@ -15,7 +15,7 @@ Rails.application.routes.draw do
resource :avatar resource :avatar
resource :role resource :role
resource :events resource :events
resources :credentials, only: [ :index, :new, :create, :destroy ] resources :credentials, except: %i[ show new ]
resources :push_subscriptions resources :push_subscriptions
@@ -0,0 +1,6 @@
class AddAaguidAndBackedUpToIdentityCredentials < ActiveRecord::Migration[8.2]
def change
add_column :identity_credentials, :aaguid, :string
add_column :identity_credentials, :backed_up, :boolean
end
end
Generated
+3 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do ActiveRecord::Schema[8.2].define(version: 2026_02_19_095815) do
create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "accesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.datetime "accessed_at" t.datetime "accessed_at"
t.uuid "account_id", null: false t.uuid "account_id", null: false
@@ -345,6 +345,8 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do
end end
create_table "identity_credentials", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "identity_credentials", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "aaguid"
t.boolean "backed_up"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.string "credential_id", null: false t.string "credential_id", null: false
t.uuid "identity_id", null: false t.uuid "identity_id", null: false
+10
View File
@@ -3,5 +3,15 @@ module ActionPack::WebAuthn
def relying_party def relying_party
RelyingParty.new RelyingParty.new
end end
def attestation_verifiers
@attestation_verifiers ||= {
"none" => Authenticator::AttestationVerifiers::None.new
}
end
def register_attestation_verifier(format, verifier)
attestation_verifiers[format.to_s] = verifier
end
end end
end end
@@ -38,7 +38,7 @@
class ActionPack::WebAuthn::Authenticator::Attestation class ActionPack::WebAuthn::Authenticator::Attestation
attr_reader :authenticator_data, :format, :attestation_statement attr_reader :authenticator_data, :format, :attestation_statement
delegate :credential_id, :public_key, :public_key_bytes, :sign_count, to: :authenticator_data delegate :credential_id, :public_key, :public_key_bytes, :sign_count, :aaguid, :backed_up?, to: :authenticator_data
def self.decode(bytes) def self.decode(bytes)
cbor = ActionPack::WebAuthn::CborDecoder.decode(bytes) cbor = ActionPack::WebAuthn::CborDecoder.decode(bytes)
@@ -25,10 +25,10 @@
# In addition to the base Response validations, this class verifies: # In addition to the base Response validations, this class verifies:
# #
# * The client data type is "webauthn.create" # * The client data type is "webauthn.create"
# * The attestation format is "none" (other formats require certificate verification) # * The attestation format has a registered verifier
# * The attestation statement passes format-specific verification
# #
class ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::WebAuthn::Authenticator::Response class ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::WebAuthn::Authenticator::Response
SUPPORTED_ATTESTATION_FORMATS = %w[ none ].freeze
attr_reader :attestation_object attr_reader :attestation_object
def initialize(attestation_object:, **attributes) def initialize(attestation_object:, **attributes)
@@ -43,9 +43,13 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::Web
raise InvalidResponseError, "Client data type is not webauthn.create" raise InvalidResponseError, "Client data type is not webauthn.create"
end end
unless SUPPORTED_ATTESTATION_FORMATS.include?(attestation.format) verifier = ActionPack::WebAuthn.attestation_verifiers[attestation.format]
unless verifier
raise InvalidResponseError, "Unsupported attestation format: #{attestation.format}" raise InvalidResponseError, "Unsupported attestation format: #{attestation.format}"
end end
verifier.verify!(attestation, client_data_json: client_data_json)
end end
def attestation def attestation
@@ -0,0 +1,24 @@
# = Action Pack WebAuthn None Attestation Verifier
#
# Verifies attestation responses with the "none" format, which indicates the
# authenticator did not provide any attestation statement. This is the default
# format used by most consumer authenticators.
#
# == Implementing Custom Verifiers
#
# To support other attestation formats (e.g., "packed", "fido-u2f"), implement
# a class with the same +verify!+ interface and register it:
#
# ActionPack::WebAuthn.register_attestation_verifier("packed", MyPackedVerifier.new)
#
# The +verify!+ method receives the decoded +Attestation+ object and the raw
# +client_data_json+ bytes. Raise +InvalidResponseError+ if verification fails.
#
class ActionPack::WebAuthn::Authenticator::AttestationVerifiers::None
def verify!(attestation, client_data_json:)
if attestation.attestation_statement.present?
raise ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError,
"Attestation statement must be empty for 'none' format"
end
end
end
@@ -57,7 +57,7 @@ class ActionPack::WebAuthn::Authenticator::Data
BACKUP_STATE_FLAG = 0x10 BACKUP_STATE_FLAG = 0x10
ATTESTED_CREDENTIAL_DATA_FLAG = 0x40 ATTESTED_CREDENTIAL_DATA_FLAG = 0x40
attr_reader :bytes, :relying_party_id_hash, :flags, :sign_count, :credential_id, :public_key_bytes attr_reader :bytes, :relying_party_id_hash, :flags, :sign_count, :aaguid, :credential_id, :public_key_bytes
class << self class << self
def wrap(data) def wrap(data)
@@ -81,10 +81,13 @@ class ActionPack::WebAuthn::Authenticator::Data
sign_count = bytes[position, SIGN_COUNT_LENGTH].pack("C*").unpack1("N") sign_count = bytes[position, SIGN_COUNT_LENGTH].pack("C*").unpack1("N")
position += SIGN_COUNT_LENGTH position += SIGN_COUNT_LENGTH
aaguid = nil
credential_id = nil credential_id = nil
public_key_bytes = nil public_key_bytes = nil
if flags & ATTESTED_CREDENTIAL_DATA_FLAG != 0 if flags & ATTESTED_CREDENTIAL_DATA_FLAG != 0
aaguid_bytes = bytes[position, AAGUID_LENGTH].pack("C*")
aaguid = aaguid_bytes.unpack("H8H4H4H4H12").join("-")
position += AAGUID_LENGTH position += AAGUID_LENGTH
credential_id_length = bytes[position, CREDENTIAL_ID_LENGTH_BYTES].pack("C*").unpack1("n") credential_id_length = bytes[position, CREDENTIAL_ID_LENGTH_BYTES].pack("C*").unpack1("n")
@@ -101,17 +104,19 @@ class ActionPack::WebAuthn::Authenticator::Data
relying_party_id_hash: relying_party_id_hash, relying_party_id_hash: relying_party_id_hash,
flags: flags, flags: flags,
sign_count: sign_count, sign_count: sign_count,
aaguid: aaguid,
credential_id: credential_id, credential_id: credential_id,
public_key_bytes: public_key_bytes public_key_bytes: public_key_bytes
) )
end end
end end
def initialize(bytes:, relying_party_id_hash:, flags:, sign_count:, credential_id:, public_key_bytes:) def initialize(bytes:, relying_party_id_hash:, flags:, sign_count:, aaguid: nil, credential_id:, public_key_bytes:)
@bytes = bytes @bytes = bytes
@relying_party_id_hash = relying_party_id_hash @relying_party_id_hash = relying_party_id_hash
@flags = flags @flags = flags
@sign_count = sign_count @sign_count = sign_count
@aaguid = aaguid
@credential_id = credential_id @credential_id = credential_id
@public_key_bytes = public_key_bytes @public_key_bytes = public_key_bytes
end end
+1 -1
View File
@@ -1,3 +1,3 @@
class ActionPack::WebAuthn::Current < ActiveSupport::CurrentAttributes class ActionPack::WebAuthn::Current < ActiveSupport::CurrentAttributes
attribute :host attribute :host, :origin
end end
@@ -1,5 +1,5 @@
class ActionPack::WebAuthn::PublicKeyCredential class ActionPack::WebAuthn::PublicKeyCredential
attr_reader :id, :public_key, :sign_count, :transports, :owner attr_reader :id, :public_key, :sign_count, :aaguid, :backed_up, :transports, :owner
class << self class << self
def create(client_data_json:, attestation_object:, challenge:, origin:, transports: [], owner: nil) def create(client_data_json:, attestation_object:, challenge:, origin:, transports: [], owner: nil)
@@ -14,16 +14,20 @@ class ActionPack::WebAuthn::PublicKeyCredential
id: response.attestation.credential_id, id: response.attestation.credential_id,
public_key: response.attestation.public_key, public_key: response.attestation.public_key,
sign_count: response.attestation.sign_count, sign_count: response.attestation.sign_count,
aaguid: response.attestation.aaguid,
backed_up: response.attestation.backed_up?,
transports: transports, transports: transports,
owner: owner owner: owner
) )
end end
end end
def initialize(id:, public_key:, sign_count:, transports: [], owner: nil) def initialize(id:, public_key:, sign_count:, aaguid: nil, backed_up: nil, transports: [], owner: nil)
@id = id @id = id
@public_key = public_key @public_key = public_key
@sign_count = sign_count @sign_count = sign_count
@aaguid = aaguid
@backed_up = backed_up
@transports = transports @transports = transports
@owner = owner @owner = owner
end end
@@ -39,5 +43,6 @@ class ActionPack::WebAuthn::PublicKeyCredential
response.validate!(challenge: challenge, origin: origin) response.validate!(challenge: challenge, origin: origin)
@sign_count = response.authenticator_data.sign_count @sign_count = response.authenticator_data.sign_count
@backed_up = response.authenticator_data.backed_up?
end end
end end
@@ -68,7 +68,11 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W
# [+:exclude_credentials+] # [+:exclude_credentials+]
# Optional. Existing credentials to exclude from registration. Each must # Optional. Existing credentials to exclude from registration. Each must
# respond to +id+ and +transports+. # respond to +id+ and +transports+.
def initialize(id:, name:, display_name:, resident_key: :preferred, exclude_credentials: [], **attrs) #
# [+:attestation+]
# Optional. The attestation conveyance preference. One of +:none+,
# +:indirect+, +:direct+, or +:enterprise+. Defaults to +:none+.
def initialize(id:, name:, display_name:, resident_key: :preferred, exclude_credentials: [], attestation: :none, **attrs)
super(**attrs) super(**attrs)
@id = id @id = id
@@ -76,6 +80,7 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W
@display_name = display_name @display_name = display_name
@resident_key = resident_key @resident_key = resident_key
@exclude_credentials = exclude_credentials @exclude_credentials = exclude_credentials
@attestation = validate_attestation(attestation)
end end
# Returns a Hash suitable for JSON serialization and passing to the # Returns a Hash suitable for JSON serialization and passing to the
@@ -104,10 +109,24 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::W
json[:excludeCredentials] = @exclude_credentials.map { |credential| exclude_credential_json(credential) } json[:excludeCredentials] = @exclude_credentials.map { |credential| exclude_credential_json(credential) }
end end
if @attestation != :none
json[:attestation] = @attestation.to_s
end
json json
end end
private private
ATTESTATION_PREFERENCES = %i[ none indirect direct enterprise ].freeze
def validate_attestation(value)
if ATTESTATION_PREFERENCES.include?(value)
value
else
raise ArgumentError, "Invalid attestation preference: #{value.inspect}. Must be one of: #{ATTESTATION_PREFERENCES.map(&:inspect).join(", ")}"
end
end
def exclude_credential_json(credential) def exclude_credential_json(credential)
hash = { type: "public-key", id: credential.id } hash = { type: "public-key", id: credential.id }
hash[:transports] = credential.transports if credential.transports.any? hash[:transports] = credential.transports if credential.transports.any?
@@ -44,7 +44,7 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
challenge = session[:webauthn_challenge] challenge = session[:webauthn_challenge]
params = assertion_params(challenge: challenge) params = assertion_params(challenge: challenge)
params[:response][:signature] = Base64.urlsafe_encode64("invalid", padding: false) params[:passkey][:signature] = Base64.urlsafe_encode64("invalid", padding: false)
post session_passkey_url, params: params post session_passkey_url, params: params
@@ -59,8 +59,8 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
get new_session_url get new_session_url
post session_passkey_url, params: { post session_passkey_url, params: {
credential_id: "nonexistent", passkey: {
response: { id: "nonexistent",
client_data_json: Base64.urlsafe_encode64("{}", padding: false), client_data_json: Base64.urlsafe_encode64("{}", padding: false),
authenticator_data: Base64.urlsafe_encode64("x", padding: false), authenticator_data: Base64.urlsafe_encode64("x", padding: false),
signature: Base64.urlsafe_encode64("x", padding: false) signature: Base64.urlsafe_encode64("x", padding: false)
@@ -89,8 +89,8 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
get new_session_url get new_session_url
post session_passkey_url(format: :json), params: { post session_passkey_url(format: :json), params: {
credential_id: "nonexistent", passkey: {
response: { id: "nonexistent",
client_data_json: Base64.urlsafe_encode64("{}", padding: false), client_data_json: Base64.urlsafe_encode64("{}", padding: false),
authenticator_data: Base64.urlsafe_encode64("x", padding: false), authenticator_data: Base64.urlsafe_encode64("x", padding: false),
signature: Base64.urlsafe_encode64("x", padding: false) signature: Base64.urlsafe_encode64("x", padding: false)
@@ -116,8 +116,8 @@ class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
signature = sign(authenticator_data, client_data_json) signature = sign(authenticator_data, client_data_json)
{ {
credential_id: @credential.credential_id, passkey: {
response: { id: @credential.credential_id,
client_data_json: client_data_json, client_data_json: client_data_json,
authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false), authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false),
signature: Base64.urlsafe_encode64(signature, padding: false) signature: Base64.urlsafe_encode64(signature, padding: false)
@@ -98,7 +98,7 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
assert_equal "Origin does not match", error.message assert_equal "Origin does not match", error.message
end end
test "validate! raises when attestation format is not supported" do test "validate! raises when attestation format is not registered" do
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new( response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json, client_data_json: @client_data_json,
attestation_object: build_attestation_object(user_verified: true, format: "packed") attestation_object: build_attestation_object(user_verified: true, format: "packed")
@@ -111,6 +111,24 @@ class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSuppo
assert_equal "Unsupported attestation format: packed", error.message assert_equal "Unsupported attestation format: packed", error.message
end end
test "validate! calls registered verifier for custom format" do
verified = false
custom_verifier = Object.new
custom_verifier.define_singleton_method(:verify!) { |_attestation, client_data_json:| verified = true }
ActionPack::WebAuthn.register_attestation_verifier("packed", custom_verifier)
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: build_attestation_object(user_verified: true, format: "packed")
)
response.validate!(challenge: @challenge, origin: @origin)
assert verified
ensure
ActionPack::WebAuthn.attestation_verifiers.delete("packed")
end
private private
def build_attestation_object(user_verified:, format: "none") def build_attestation_object(user_verified:, format: "none")
auth_data = build_authenticator_data(user_verified: user_verified) auth_data = build_authenticator_data(user_verified: user_verified)
@@ -0,0 +1,33 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::AttestationVerifiers::NoneTest < ActiveSupport::TestCase
setup do
@verifier = ActionPack::WebAuthn::Authenticator::AttestationVerifiers::None.new
end
test "verify! passes with nil attestation statement" do
attestation = stub(attestation_statement: nil)
assert_nothing_raised do
@verifier.verify!(attestation, client_data_json: "")
end
end
test "verify! passes with empty attestation statement" do
attestation = stub(attestation_statement: {})
assert_nothing_raised do
@verifier.verify!(attestation, client_data_json: "")
end
end
test "verify! raises with non-empty attestation statement" do
attestation = stub(attestation_statement: { "sig" => "abc" })
error = assert_raises(ActionPack::WebAuthn::Authenticator::Response::InvalidResponseError) do
@verifier.verify!(attestation, client_data_json: "")
end
assert_equal "Attestation statement must be empty for 'none' format", error.message
end
end
@@ -84,6 +84,34 @@ class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSup
], options.as_json[:excludeCredentials] ], options.as_json[:excludeCredentials]
end end
test "as_json excludes attestation when none" do
assert_nil @options.as_json[:attestation]
end
test "as_json includes attestation when not none" do
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
id: "user-123",
name: "user@example.com",
display_name: "Test User",
attestation: :direct,
relying_party: @relying_party
)
assert_equal "direct", options.as_json[:attestation]
end
test "raises with invalid attestation preference" do
assert_raises(ArgumentError) do
ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
id: "user-123",
name: "user@example.com",
display_name: "Test User",
attestation: :invalid,
relying_party: @relying_party
)
end
end
test "as_json excludeCredentials omits transports when empty" do test "as_json excludeCredentials omits transports when empty" do
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new( options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
id: "user-123", id: "user-123",