Merge pull request #2623 from basecamp/passkeys

Passkeys
This commit is contained in:
Stanko Krtalić
2026-03-18 14:54:54 +01:00
committed by GitHub
92 changed files with 5010 additions and 16 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

@@ -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

+20 -6
View File
@@ -1,19 +1,33 @@
.access_tokens_table {
.access-tokens {
border-collapse: collapse;
font-size: var(--text-small);
inline-size: 100%;
margin-block-end: 2lh;
td, th {
border-block-end: 1px solid var(--color-ink-light);
padding-inline: var(--inline-space);
border-block-end: 1px solid var(--color-ink-lighter);
text-align: start;
&:first-child {
inline-size: 100%;
}
&:not(:first-child) {
padding-inline-start: var(--inline-space);
}
&:not(:last-child) {
padding-inline-end: var(--inline-space);
}
}
th {
color: var(--color-ink-dark);
font-size: var(--text-x-small);
text-transform: uppercase;
}
tr:nth-of-type(even) {
background-color: var(--color-ink-lightest);
td {
padding-block: 8px;
}
}
}
+43
View File
@@ -0,0 +1,43 @@
@layer components {
.credential {
border-block-start: var(--border);
list-style: none;
&:last-child {
border-block-end: var(--border);
}
}
.credential__link {
align-items: center;
block-size: 1.75lh;
color: currentcolor;
display: flex;
gap: 1ch;
padding-inline: 1ch;
@media (any-hover: hover) {
&:hover {
background: var(--color-ink-lightest);
.credential__arrow {
opacity: 0.66;
}
}
}
}
.credential__arrow {
margin-inline-start: auto;
opacity: 0;
}
[data-passkey-errors] [data-passkey-error] {
display: none;
}
[data-passkey-errors][data-passkey-error-state="error"] [data-passkey-error="error"],
[data-passkey-errors][data-passkey-error-state="cancelled"] [data-passkey-error="cancelled"] {
display: block;
}
}
+1
View File
@@ -27,6 +27,7 @@
.icon--art { --svg: url("art.svg "); }
.icon--assigned { --svg: url("assigned.svg "); }
.icon--attachment { --svg: url("attachment.svg "); }
.icon--authentication { --svg: url("authentication.svg "); }
.icon--bell-alert { --svg: url("bell-alert.svg "); }
.icon--bell-off { --svg: url("bell-off.svg "); }
.icon--bell { --svg: url("bell.svg "); }
+27 -2
View File
@@ -31,9 +31,10 @@
.txt-capitalize-first-letter::first-letter { text-transform: capitalize; }
.txt-link { color: var(--color-link); text-decoration: underline; }
.font-weight-normal { font-weight: normal; }
.font-weight-normal { font-weight: 400; }
.font-weight-medium { font-weight: 500; }
.font-weight-semibold { font-weight: 600; }
.font-weight-bold { font-weight: bold; }
.font-weight-bold { font-weight: 700; }
.font-weight-black { font-weight: 900; }
/* Flexbox and Grid */
@@ -283,4 +284,28 @@
display: none;
}
}
.hide-on-dark-mode {
html[data-theme="dark"] & {
display: none;
}
html:not([data-theme]) & {
@media (prefers-color-scheme: dark) {
display: none;
}
}
}
.hide-on-light-mode {
html[data-theme="light"] & {
display: none;
}
html:not([data-theme]) & {
@media (prefers-color-scheme: light) {
display: none;
}
}
}
}
@@ -0,0 +1,7 @@
class My::PasskeyChallengesController < ActionPack::Passkey::ChallengesController
include Authentication
include Authorization
allow_unauthenticated_access
disallow_account_scope
end
+34
View File
@@ -0,0 +1,34 @@
class My::PasskeysController < ApplicationController
include ActionPack::Passkey::Request
before_action :set_passkey, only: %i[ edit update destroy ]
def index
@passkeys = Current.identity.passkeys.order(name: :asc, created_at: :desc)
@creation_options = passkey_creation_options(holder: Current.identity)
end
def create
passkey = Current.identity.passkeys.register(passkey_creation_params)
redirect_to edit_my_passkey_path(passkey, created: true)
end
def edit
end
def update
@passkey.update!(params.expect(passkey: [ :name ]))
redirect_to my_passkeys_path
end
def destroy
@passkey.destroy!
redirect_to my_passkeys_path
end
private
def set_passkey
@passkey = Current.identity.passkeys.find(params[:id])
end
end
@@ -0,0 +1,33 @@
class Sessions::PasskeysController < ApplicationController
include ActionPack::Passkey::Request
disallow_account_scope
require_unauthenticated_access
rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded
def create
if credential = ActionPack::Passkey.authenticate(passkey_request_params)
start_new_session_for credential.holder
respond_to do |format|
format.html { redirect_to after_authentication_url }
format.json { render json: { session_token: session_token } }
end
else
respond_to do |format|
format.html { redirect_to new_session_path, alert: "That passkey didn't work. Try again." }
format.json { render json: { message: "That passkey didn't work. Try again." }, status: :unauthorized }
end
end
end
private
def rate_limit_exceeded
rate_limit_exceeded_message = "Try again later."
respond_to do |format|
format.html { redirect_to new_session_path, alert: rate_limit_exceeded_message }
format.json { render json: { message: rate_limit_exceeded_message }, status: :too_many_requests }
end
end
end
+3
View File
@@ -1,4 +1,6 @@
class SessionsController < ApplicationController
include ActionPack::Passkey::Request
disallow_account_scope
require_unauthenticated_access except: :destroy
rate_limit to: 10, within: 3.minutes, only: :create, with: :rate_limit_exceeded
@@ -6,6 +8,7 @@ class SessionsController < ApplicationController
layout "public"
def new
@request_options = passkey_request_options
end
def create
+1
View File
@@ -6,3 +6,4 @@ import "controllers"
import "lexxy"
import "@rails/actiontext"
import "lib/action_pack/passkey"
+246
View File
@@ -0,0 +1,246 @@
// JS companion for the ActionPack::Passkey Ruby helpers.
//
// Binds click handlers to passkey buttons and manages the WebAuthn ceremony
// lifecycle (challenge refresh, credential creation/authentication, form submission).
//
// Expected data attributes:
// [data-passkey="create"] — triggers the registration ceremony
// [data-passkey="sign_in"] — triggers the authentication ceremony
// [data-passkey-mediation="conditional"] — on a <form>, enables autofill-assisted sign in
// [data-passkey-errors] — container whose data-passkey-error-state is set on failure
// [data-passkey-error="error|cancelled"] — children shown/hidden via CSS based on error state
// [data-passkey-field="..."] — hidden fields populated before form submission
//
// Custom events (all bubble):
// passkey:start — ceremony begun
// passkey:success — credential obtained, form about to submit
// passkey:error — ceremony failed; detail: { error, cancelled }
//
// Meta tags (rendered by the Ruby form helpers):
// <meta name="passkey-creation-options"> — JSON WebAuthn creation options
// <meta name="passkey-request-options"> — JSON WebAuthn request options
// <meta name="passkey-challenge-url"> — endpoint to refresh the challenge nonce
import { register, authenticate } from "lib/action_pack/webauthn"
let listeners
let currentDocument
document.addEventListener("DOMContentLoaded", setup)
document.addEventListener("turbo:load", setup)
document.addEventListener("turbo:before-cache", teardown)
// Set error state on the nearest [data-passkey-errors] container.
// The app's CSS is responsible for showing/hiding children based on
// the data-passkey-error-state attribute ("error" or "cancelled").
document.addEventListener("passkey:error", ({ target, detail: { cancelled } }) => {
const container = target.closest("[data-passkey-errors]")
if (container) {
container.dataset.passkeyErrorState = cancelled ? "cancelled" : "error"
}
})
// Bind click handlers to passkey buttons and attempt conditional mediation.
// Guards against duplicate setup.
function setup() {
if (currentDocument !== document.documentElement) {
currentDocument = document.documentElement
listeners?.abort()
listeners = new AbortController()
for (const button of document.querySelectorAll('[data-passkey="create"]')) {
button.addEventListener("click", () => createPasskey(button), { signal: listeners.signal })
}
for (const button of document.querySelectorAll('[data-passkey="sign_in"]')) {
button.addEventListener("click", () => signInWithPasskey(button), { signal: listeners.signal })
}
attemptConditionalMediation()
}
}
// Reset transient DOM state and unbind event handlers to prevent leaks and duplicate handlers.
function teardown() {
currentDocument = null
listeners?.abort()
for (const button of document.querySelectorAll('[data-passkey][disabled]')) {
button.disabled = false
}
for (const container of document.querySelectorAll("[data-passkey-errors]")) {
delete container.dataset.passkeyErrorState
}
}
// Run the WebAuthn registration ceremony: refresh the challenge, prompt the
// browser to create a credential, fill the form's hidden fields, and submit.
async function createPasskey(button) {
const form = button.closest("form")
if (form) {
button.disabled = true
button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
try {
if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser")
const creationOptions = getCreationOptions()
if (!creationOptions) throw new Error("Missing passkey creation options")
await refreshChallenge(creationOptions)
const passkey = await register(creationOptions)
button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
fillCreateForm(form, passkey)
form.submit()
} catch (error) {
button.disabled = false
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
}
}
}
function passkeysAvailable() {
return !!window.PublicKeyCredential
}
// Read WebAuthn creation options from the <meta> tag rendered by
// +passkey_creation_options_meta_tag+. Returns undefined if the tag is missing.
function getCreationOptions() {
return getOptions("passkey-creation-options")
}
// Parse and return the JSON content of a <meta> tag by name.
function getOptions(name) {
const meta = document.querySelector(`meta[name="${name}"]`)
if (meta) {
return JSON.parse(meta.content)
}
}
// POST to the challenge endpoint to get a fresh nonce, preventing replay attacks
// when the page has been open for a while before the user initiates the ceremony.
async function refreshChallenge(options) {
const url = document.querySelector('meta[name="passkey-challenge-url"]')?.content
if (!url) throw new Error("Missing passkey challenge URL")
const token = document.querySelector('meta[name="csrf-token"]')?.content
const response = await fetch(url, {
method: "POST",
credentials: "same-origin",
headers: {
"X-CSRF-Token": token,
"Accept": "application/json"
}
})
if (!response.ok) throw new Error("Failed to refresh challenge")
const { challenge } = await response.json()
options.challenge = challenge
}
// Populate the registration form's hidden fields with the credential response.
// Clones the transports template input for each reported transport.
function fillCreateForm(form, passkey) {
form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json
form.querySelector('[data-passkey-field="attestation_object"]').value = passkey.attestation_object
const template = form.querySelector('[data-passkey-field="transports"]')
for (const transport of passkey.transports) {
const input = template.cloneNode()
input.value = transport
template.before(input)
}
template.remove()
}
// Run the WebAuthn authentication ceremony: refresh the challenge, prompt the
// browser to sign with an existing credential, fill the form, and submit.
async function signInWithPasskey(button) {
const form = button.closest("form")
if (form) {
button.disabled = true
button.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
try {
if (!passkeysAvailable()) throw new Error("Passkeys are not supported by this browser")
const requestOptions = getRequestOptions()
if (!requestOptions) throw new Error("Missing passkey request options")
await refreshChallenge(requestOptions)
const passkey = await authenticate(requestOptions)
button.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
fillSignInForm(form, passkey)
form.submit()
} catch (error) {
button.disabled = false
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
button.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
}
}
}
// Read WebAuthn request options from the <meta> tag rendered by
// +passkey_request_options_meta_tag+. Returns undefined if the tag is missing.
function getRequestOptions() {
return getOptions("passkey-request-options")
}
// Populate the authentication form's hidden fields with the assertion response.
function fillSignInForm(form, passkey) {
form.querySelector('[data-passkey-field="id"]').value = passkey.id
form.querySelector('[data-passkey-field="client_data_json"]').value = passkey.client_data_json
form.querySelector('[data-passkey-field="authenticator_data"]').value = passkey.authenticator_data
form.querySelector('[data-passkey-field="signature"]').value = passkey.signature
}
// Start the conditional mediation (autofill) ceremony if the page opts in with
// a form[data-passkey-mediation="conditional"] and the browser supports it.
// Unlike the button-driven ceremonies, this runs automatically on page load.
async function attemptConditionalMediation() {
if (await conditionalMediationAvailable()) {
const form = document.querySelector('form[data-passkey-mediation="conditional"]')
form.dispatchEvent(new CustomEvent("passkey:start", { bubbles: true }))
const requestOptions = getRequestOptions()
try {
await refreshChallenge(requestOptions)
const passkey = await authenticate(requestOptions, { mediation: "conditional" })
form.dispatchEvent(new CustomEvent("passkey:success", { bubbles: true }))
fillSignInForm(form, passkey)
form.submit()
} catch (error) {
const cancelled = error.name === "AbortError" || error.name === "NotAllowedError"
form.dispatchEvent(new CustomEvent("passkey:error", { bubbles: true, detail: { error, cancelled } }))
}
}
}
// Check all preconditions for conditional mediation: the page has opted in,
// request options are present, the browser supports passkeys, and the browser
// supports the conditional mediation UI (autofill).
async function conditionalMediationAvailable() {
return isConditionalMediationFormPresent() &&
getRequestOptions() &&
passkeysAvailable() &&
await window.PublicKeyCredential.isConditionalMediationAvailable?.()
}
function isConditionalMediationFormPresent() {
return !!document.querySelector('form[data-passkey-mediation="conditional"]')
}
@@ -0,0 +1,83 @@
// Thin wrapper around the browser WebAuthn API (navigator.credentials).
//
// Handles the base64url ↔ ArrayBuffer conversions required by the spec so
// callers can work with plain JSON objects from the server-rendered meta tags.
// Call navigator.credentials.create() with the given creation options.
// Returns { client_data_json, attestation_object, transports } with all
// binary fields encoded as base64url strings ready for form submission.
export async function register(options) {
const publicKey = prepareCreationOptions(options)
const credential = await navigator.credentials.create({ publicKey })
return {
client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),
attestation_object: bufferToBase64url(credential.response.attestationObject),
transports: credential.response.getTransports?.() || []
}
}
// Call navigator.credentials.get() with the given request options.
// Accepts an optional signal (AbortSignal) and mediation hint ("conditional"
// for autofill UI). Returns { id, client_data_json, authenticator_data, signature }
// with binary fields encoded as base64url strings.
export async function authenticate(options, { signal, mediation } = {}) {
const publicKey = prepareRequestOptions(options)
const credential = await navigator.credentials.get({ publicKey, signal, mediation })
return {
id: credential.id,
client_data_json: new TextDecoder().decode(credential.response.clientDataJSON),
authenticator_data: bufferToBase64url(credential.response.authenticatorData),
signature: bufferToBase64url(credential.response.signature)
}
}
// Convert JSON creation options into the format expected by the browser:
// decode base64url challenge, user.id, and excludeCredentials[].id into ArrayBuffers.
function prepareCreationOptions(options) {
return {
...options,
challenge: base64urlToBuffer(options.challenge),
user: { ...options.user, id: base64urlToBuffer(options.user.id) },
excludeCredentials: (options.excludeCredentials || []).map(cred => ({
...cred,
id: base64urlToBuffer(cred.id)
}))
}
}
// Convert JSON request options into the format expected by the browser:
// decode base64url challenge and allowCredentials[].id into ArrayBuffers.
// Strips allowCredentials entirely when empty so the browser prompts for
// any available credential (required for conditional mediation).
function prepareRequestOptions(options) {
const prepared = {
...options,
challenge: base64urlToBuffer(options.challenge)
}
if (options.allowCredentials?.length) {
prepared.allowCredentials = options.allowCredentials.map(cred => ({
...cred,
id: base64urlToBuffer(cred.id)
}))
} else {
delete prepared.allowCredentials
}
return prepared
}
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
}
function bufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer)
const binary = String.fromCharCode(...bytes)
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}
+6
View File
@@ -33,6 +33,12 @@ class Account::Seeder
<action-text-attachment url="https://videos.37signals.com/fizzy/assets/videos/fizzyorientation-4k.mp4" caption="Fizzy orientation" content-type="video/mp4" filename="fizzyorientation-4k.mp4"></action-text-attachment>
HTML
# TODO: Replace the video here with a screencap of creating a passkey
playground.cards.create! creator: creator, title: "Then, set up a Passkey", status: "published", description: <<~HTML
<p>Passkeys let you sign in securely without using passwords or email codes. To set one up, open the Fizzy menu and go to “<b><strong>My Profile > Manage Passkeys</b></strong>”. Using a passkey is optional, but recommended.</p>
<action-text-attachment url="https://videos.37signals.com/fizzy/assets/videos/creating_a_passkey.mp4" alt="Demo of adding a passkey" caption="Create a passkey to sign in without passwords or email codes" content-type="video/mp4" filename="creating_a_passkey.mp4"></action-text-attachment>
HTML
playground.cards.create! creator: creator, title: "Now, grab the invite link to invite someone else", status: "published", description: <<~HTML
<p>Open the Fizzy menu, select “<b><strong>+ Add people</b></strong>”, then copy the invite link. You can give this link to someone else so they can make a login for themselves in your account.</p>
<action-text-attachment url="https://videos.37signals.com/fizzy/assets/images/invite-link.gif" alt="Demo of copying invite link" caption="Get a link to invite co-workers" content-type="image/*" filename="invite-link.gif" presentation="gallery"></action-text-attachment>
+2
View File
@@ -1,6 +1,8 @@
class Identity < ApplicationRecord
include Joinable, Transferable
has_passkeys name: :email_address, display_name: -> { Current.user&.name || email_address }
has_many :access_tokens, dependent: :destroy
has_many :magic_links, dependent: :destroy
has_many :sessions, dependent: :destroy
+23
View File
@@ -0,0 +1,23 @@
class Passkey::Authenticator < Data.define(:aaguids, :name, :icon)
class << self
def find_by_aaguid(aaguid)
registry[aaguid]
end
def registry
@registry ||= Hash.new.tap do |registry|
all.each do |authenticator|
authenticator.aaguids.each do |aaguid|
registry[aaguid] = authenticator
end
end
end
end
def all
Rails.application.config_for(:passkey_aaguids).each_value.map do |attrs|
new(aaguids: attrs[:aaguids], name: attrs[:name], icon: attrs[:icon])
end
end
end
end
@@ -11,6 +11,10 @@
<p>This code will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.</p>
<% if account = @magic_link.identity.accounts.last %>
<p>P.S. You can make your account more secure and sign-in faster with a <%= link_to "Passkey", my_passkeys_url(script_name: account.slug) %></p>
<% end %>
<p class="footer">Need help? <%= mail_to "support@fizzy.do", "Send us an email"%>.
</p>
@@ -7,3 +7,7 @@ Please enter this 6-character verification code to finish creating your account:
<%= @magic_link.code %>
This code will work for <%= distance_of_time_in_words(MagicLink::EXPIRATION_TIME) %>.
<% if account = @magic_link.identity.accounts.last %>
P.S. If you want to sign-in faster, and make your account more secure, add a passkey: <%= my_passkeys_url(script_name: account.slug) %>
<% end %>
+1 -1
View File
@@ -11,7 +11,7 @@
<section class="panel panel--wide shadow center webhooks">
<% if @access_tokens.any? %>
<p class="margin-none-block-start">Tokens you have generated that can be used to access the Fizzy API.</p>
<table class="access_tokens_table margin-block-end-double max-width txt-small">
<table class="access-tokens margin-block-end-double max-width txt-small">
<thead>
<tr>
<th>Description</th>
+13
View File
@@ -0,0 +1,13 @@
<li class="credential" style="view-transition-name: <%= dom_id(passkey) %>">
<%= link_to edit_my_passkey_path(passkey), class: "credential__link" do %>
<% if icon = passkey.authenticator&.icon %>
<%= image_tag icon[:light], size: 24, class: "flex-item-no-shrink hide-on-dark-mode", aria: { hidden: true } %>
<%= image_tag icon[:dark], size: 24, class: "flex-item-no-shrink hide-on-light-mode", aria: { hidden: true } %>
<% else %>
<%= image_tag "passkeys/generic_light.svg", size: 24, class: "flex-item-no-shrink hide-on-dark-mode", aria: { hidden: true } %>
<%= image_tag "passkeys/generic_dark.svg", size: 24, class: "flex-item-no-shrink hide-on-light-mode", aria: { hidden: true } %>
<% end %>
<strong class="overflow-ellipsis min-width"><%= passkey.name.presence || "Passkey" %></strong>
<strong class="credential__arrow">→</strong>
<% end %>
</li>
+33
View File
@@ -0,0 +1,33 @@
<% @page_title = "Edit Passkey" %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "Passkeys", my_passkeys_path, "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: @passkey, scope: :passkey, url: my_passkey_path(@passkey), html: { class: "flex flex-column gap" } do |form| %>
<div class="flex flex-column gap-half">
<strong><%= form.label "Name your passkey" %></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--link center" %>
<% end %>
<div class="txt-align-center">
<%= button_to my_passkey_path(@passkey), 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>
+43
View File
@@ -0,0 +1,43 @@
<% @page_title = "Passkeys" %>
<% content_for :head do %>
<%= passkey_creation_options_meta_tag(@creation_options) %>
<% end %>
<% content_for :header do %>
<div class="header__actions header__actions--start">
<%= back_link_to "My profile", user_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" data-passkey-errors>
<p class="font-weight-medium margin-none-block-start">Passkeys let you sign in securely without a password or email code.</p>
<% if @passkeys.any? %>
<ul class="margin-none-block-start margin-block-end-double unpad">
<%= render partial: "my/passkeys/passkey", collection: @passkeys %>
</ul>
<% end %>
<footer>
<%= passkey_creation_button my_passkeys_path, class: "btn btn--link center txt-medium" do %>
<%= icon_tag "add" %>
<span>Register a passkey</span>
<% end %>
<p class="txt-small txt-subtle txt-balance margin-none-block-end">
Your browser will prompt you to create a passkey using your device's biometrics, PIN, or security key
</p>
<p data-passkey-error="error" class="txt-negative">
Something went wrong while registering your passkey.
</p>
<p data-passkey-error="cancelled" class="txt-subtle">
Passkey registration was cancelled.
Try again when you are ready.
</p>
</footer>
</section>
+11 -4
View File
@@ -1,26 +1,33 @@
<% @page_title = "Enter your email" %>
<% content_for :head do %>
<%= passkey_request_options_meta_tag(@request_options) %>
<% end %>
<div class="panel panel--centered flex flex-column gap-half">
<h1 class="txt-x-large font-weight-black margin-block-end">Get into Fizzy</h1>
<%= form_with url: session_path, class: "flex flex-column gap-half txt-medium" do |form| %>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor">
<%= form.email_field :email_address, required: true, class: "input txt-large full-width", autofocus: true, autocomplete: "username", placeholder: "Enter your email address…" %>
<%= form.email_field :email_address, required: true, class: "input txt-large full-width", autofocus: true, autocomplete: "username webauthn", placeholder: "Enter your email address…" %>
</label>
</div>
<% if Account.accepting_signups? %>
<p><strong>New here?</strong> <%= link_to "Sign up", new_signup_path %> to create an account. <strong>Already have an account?</strong> Enter your email and well get you signed in.</p>
<p><strong>New here?</strong> <%= link_to "Sign up", new_signup_path %> to create an account. <strong>Already have an account?</strong> Enter your email and we'll get you signed in.</p>
<% else %>
<p>Enter your email and well get you signed in.</p>
<p>Enter your email and we'll get you signed in.</p>
<% end %>
<button type="submit" id="log_in" class="btn btn--link center txt-medium">
<span>Lets go</span>
<span>Let's go</span>
<%= icon_tag "arrow-right" %>
</button>
<% end %>
<%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path, mediation: "conditional", class: "btn btn--link center txt-medium", hidden: true %>
</div>
<% content_for :footer do %>
+5
View File
@@ -41,6 +41,11 @@
<% if Current.user == @user %>
<hr class="separator--horizontal full-width flex-item-grow margin-block-start-double" style="--border-color: var(--color-ink-light);" aria-hidden="true">
<%= link_to my_passkeys_path, class: "btn txt-x-small center" do %>
<%= icon_tag "authentication" %>
<span>Manage passkeys</span>
<% end %>
<%= button_to session_path(script_name: nil), method: :delete, class: "btn txt-x-small center", form: { data: { turbo: false, controller: "clear-offline-cache", action: "submit->clear-offline-cache#clearCache" } } do %>
<span>Sign out of Fizzy on this device</span>
<% end %>
+1 -1
View File
@@ -41,7 +41,7 @@ if [ "$USE_TAILSCALE" = "1" ]; then
exit 1
fi
TS_STATUS=$(tailscale status --self --json 2>&1)
TS_STATUS=$(tailscale status --self --json 2>/dev/null)
if [ $? -ne 0 ]; then
echo "Error: tailscale not logged in" >&2
exit 1
+4
View File
@@ -1,6 +1,7 @@
require_relative "boot"
require "rails/all"
require_relative "../lib/fizzy"
require_relative "../lib/action_pack/railtie"
Bundler.require(*Rails.groups)
@@ -25,6 +26,9 @@ module Fizzy
g.orm :active_record, primary_key_type: :uuid
end
config.action_pack.passkey.draw_routes = false
config.action_pack.passkey.challenge_url = -> { my_passkey_challenge_path(script_name: "") }
config.mission_control.jobs.http_basic_auth_enabled = false
end
end
+1
View File
@@ -10,6 +10,7 @@ pin "@rails/request.js", to: "@rails--request.js" # @0.0.13
pin_all_from "app/javascript/controllers", under: "controllers"
pin_all_from "app/javascript/helpers", under: "helpers"
pin_all_from "app/javascript/lib", under: "lib"
pin_all_from "app/javascript/initializers", under: "initializers"
pin_all_from "app/javascript/bridge/initializers", under: "bridge/initializers"
pin_all_from "app/javascript/bridge/helpers", under: "bridge/helpers"
+3
View File
@@ -0,0 +1,3 @@
Rails.application.config.to_prepare do
ActionPack::Passkey.prepend ActionPackPasskeyInferNameFromAaguid
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
+3 -1
View File
@@ -15,7 +15,6 @@ Rails.application.routes.draw do
resource :avatar
resource :role
resource :events
resources :push_subscriptions
resources :email_addresses, param: :token do
@@ -156,6 +155,7 @@ Rails.application.routes.draw do
resources :transfers
resource :magic_link
resource :menu
resource :passkey, only: :create
end
end
@@ -172,8 +172,10 @@ Rails.application.routes.draw do
resource :landing
namespace :my do
resource :passkey_challenge, only: :create
resource :identity, only: :show
resources :access_tokens
resources :passkeys, except: %i[ show new ]
resources :pins
resource :timezone
resource :menu
@@ -0,0 +1,20 @@
class CreateActionPackPasskeys < ActiveRecord::Migration[8.2]
def change
create_table :action_pack_passkeys, id: :uuid do |t|
t.uuid :holder_id, null: false
t.string :holder_type, null: false
t.string :credential_id, null: false
t.binary :public_key, null: false
t.integer :sign_count, null: false, default: 0
t.string :name
t.text :transports
t.string :aaguid
t.boolean :backed_up
t.timestamps
t.index [ :holder_type, :holder_id ]
t.index :credential_id, unique: true
end
end
end
Generated
+16
View File
@@ -69,6 +69,22 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do
t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true
end
create_table "action_pack_passkeys", 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.string "credential_id", null: false
t.uuid "holder_id", null: false
t.string "holder_type", null: false
t.string "name"
t.binary "public_key", null: false
t.integer "sign_count", default: 0, null: false
t.text "transports"
t.datetime "updated_at", null: false
t.index ["credential_id"], name: "index_action_pack_passkeys_on_credential_id", unique: true
t.index ["holder_type", "holder_id"], name: "index_action_pack_passkeys_on_holder_type_and_holder_id"
end
create_table "action_text_rich_texts", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.uuid "account_id", null: false
t.text "body", size: :long
+16
View File
@@ -69,6 +69,22 @@ ActiveRecord::Schema[8.2].define(version: 2026_02_18_120000) do
t.index ["external_account_id"], name: "index_accounts_on_external_account_id", unique: true
end
create_table "action_pack_passkeys", id: :uuid, force: :cascade do |t|
t.string "aaguid", limit: 255
t.boolean "backed_up"
t.datetime "created_at", null: false
t.string "credential_id", limit: 255, null: false
t.uuid "holder_id", null: false
t.string "holder_type", limit: 255, null: false
t.string "name", limit: 255
t.binary "public_key", null: false
t.integer "sign_count", default: 0, null: false
t.text "transports", limit: 65535
t.datetime "updated_at", null: false
t.index ["credential_id"], name: "index_action_pack_passkeys_on_credential_id", unique: true
t.index ["holder_type", "holder_id"], name: "index_action_pack_passkeys_on_holder_type_and_holder_id"
end
create_table "action_text_rich_texts", id: :uuid, force: :cascade do |t|
t.uuid "account_id", null: false
t.text "body", limit: 4294967295
+104
View File
@@ -0,0 +1,104 @@
# ActionPack::Passkey provides WebAuthn passkey registration and authentication backed by Active Record.
#
# Passkeys are scoped to a polymorphic +holder+ (typically a User or Identity) and store the
# credential ID, public key, sign count, and transport hints needed for the WebAuthn ceremonies.
#
# == Registration
#
# Generate options for the browser's +navigator.credentials.create()+ call, then register the
# response:
#
# options = ActionPack::Passkey.creation_options(holder: current_user)
# # Pass options to the browser
#
# passkey = ActionPack::Passkey.register(params[:passkey], holder: current_user)
#
# == Authentication
#
# Generate options for the browser's +navigator.credentials.get()+ call, then authenticate the
# response:
#
# options = ActionPack::Passkey.request_options
# # Pass options to the browser
#
# passkey = ActionPack::Passkey.authenticate(params[:passkey])
#
# == Holder integration
#
# Call +has_passkeys+ in your model to set up the association and configure ceremony options
# per-holder. See ActionPack::Passkey::Holder for details.
class ActionPack::Passkey < Rails.configuration.action_pack.passkey.parent_class_name.constantize
self.table_name = "action_pack_passkeys"
belongs_to :holder, polymorphic: true
serialize :transports, coder: JSON, type: Array, default: []
class << self
# Returns a CreationOptions object for the given +holder+, suitable for passing to the
# browser's +navigator.credentials.create()+ call. Merges global defaults from the Rails
# configuration, holder-specific options from +holder.passkey_creation_options+, and any
# additional +options+ overrides.
def creation_options(holder:, **options)
ActionPack::WebAuthn::PublicKeyCredential.creation_options(
**Rails.configuration.action_pack.web_authn.default_creation_options.to_h,
**holder.passkey_creation_options.to_h,
**options
)
end
# Verifies the attestation response from the browser and persists a new passkey record.
# The +passkey+ hash should contain +client_data_json+, +attestation_object+, and +transports+
# as submitted by the registration form. The +challenge+ defaults to
# +ActionPack::WebAuthn::Current.challenge+, which is automatically populated from the session
# by ActionPack::Passkey::Request. Any additional +attributes+ (e.g. +holder+) are passed
# through to +create!+.
#
# Raises ActionPack::WebAuthn::InvalidResponseError if the attestation is invalid.
def register(passkey, challenge: ActionPack::WebAuthn::Current.challenge, **attributes)
credential = ActionPack::WebAuthn::PublicKeyCredential.register(passkey, challenge: challenge)
create!(**credential.to_h, **attributes)
end
# Returns a RequestOptions object suitable for passing to the browser's
# +navigator.credentials.get()+ call. When a +holder+ is provided, their existing credentials
# are included so the browser can offer them for selection. Merges global defaults, holder
# options, and any additional +options+ overrides.
def request_options(holder: nil, **options)
ActionPack::WebAuthn::PublicKeyCredential.request_options(
**Rails.configuration.action_pack.web_authn.default_request_options.to_h,
**holder&.passkey_request_options.to_h,
**options
)
end
# Looks up a passkey by credential ID and verifies the assertion response from the browser.
# Returns the authenticated Passkey record, or +nil+ if the credential is not found or
# verification fails.
def authenticate(passkey, challenge: ActionPack::WebAuthn::Current.challenge)
find_by(credential_id: passkey[:id])&.authenticate(passkey, challenge: challenge)
end
end
# Verifies the assertion response against this passkey's stored credential and updates the
# +sign_count+ and +backed_up+ attributes. Returns +self+ on success, or +nil+ if the
# response is invalid.
def authenticate(passkey, challenge: ActionPack::WebAuthn::Current.challenge)
credential = to_public_key_credential
credential.authenticate(passkey, challenge: challenge)
update!(sign_count: credential.sign_count, backed_up: credential.backed_up)
self
rescue ActionPack::WebAuthn::InvalidResponseError
nil
end
# Returns an ActionPack::WebAuthn::PublicKeyCredential initialized from this record's stored
# credential data.
def to_public_key_credential
ActionPack::WebAuthn::PublicKeyCredential.new(
id: credential_id,
public_key: public_key,
sign_count: sign_count,
transports: transports
)
end
end
@@ -0,0 +1,37 @@
# = Action Pack Passkey Challenges Controller
#
# Generates fresh WebAuthn challenges for passkey ceremonies. The companion
# JavaScript calls this endpoint before initiating a registration or
# authentication ceremony so that the challenge is issued just-in-time rather
# than embedded in the initial page load.
#
# The generated challenge is stored in an encrypted, HTTP-only, same-site
# cookie and simultaneously returned in the JSON response body. The cookie is
# consumed by ActionPack::Passkey::Request on the subsequent form submission.
#
# == Route
#
# By default mounted at +/rails/action_pack/passkey/challenge+ (configurable
# via +config.action_pack.passkey.routes_prefix+).
#
class ActionPack::Passkey::ChallengesController < ActionController::Base
COOKIE_NAME = :action_pack_passkey_challenge
include ActionPack::Passkey::Request
# Generates a fresh challenge, stores it in an encrypted cookie, and returns
# it as JSON. The cookie is consumed on the next passkey form submission.
def create
challenge = create_passkey_challenge
cookies.encrypted[COOKIE_NAME] = { value: challenge, httponly: true, same_site: :strict, secure: !request.local? && request.ssl? }
render json: { challenge: challenge }
end
private
def create_passkey_challenge
ActionPack::WebAuthn::PublicKeyCredential::Options.new(
challenge_expiration: Rails.configuration.action_pack.web_authn.request_challenge_expiration
).challenge
end
end
+87
View File
@@ -0,0 +1,87 @@
# View helpers for rendering passkey forms and meta tags.
#
# Include this module in your helper or ApplicationHelper to get access to:
#
# - +passkey_creation_options_meta_tag+ / +passkey_request_options_meta_tag+ — render a <meta>
# tag containing the JSON-serialized WebAuthn options for the browser credential API.
# - +passkey_creation_button+ — render a form with hidden fields for the registration ceremony.
# - +passkey_sign_in_button+ — render a form with hidden fields for the authentication
# ceremony.
module ActionPack::Passkey::FormHelper
# Renders +<meta>+ tags containing JSON-serialized creation options and the challenge endpoint
# URL for the WebAuthn registration ceremony. The companion JavaScript reads these tags to call
# +navigator.credentials.create()+.
def passkey_creation_options_meta_tag(creation_options, challenge_url: nil)
passkey_challenge_url_meta_tag(challenge_url: challenge_url) +
tag.meta(name: "passkey-creation-options", content: creation_options.to_json)
end
# Renders +<meta>+ tags containing JSON-serialized request options and the challenge endpoint
# URL for the WebAuthn authentication ceremony. The companion JavaScript reads these tags to
# call +navigator.credentials.get()+.
def passkey_request_options_meta_tag(request_options, challenge_url: nil)
passkey_challenge_url_meta_tag(challenge_url: challenge_url) +
tag.meta(name: "passkey-request-options", content: request_options.to_json)
end
# Renders a form with hidden fields for the passkey registration ceremony. The form POSTs to
# +url+ and includes hidden fields for +client_data_json+, +attestation_object+, and
# +transports+ — populated by the Stimulus controller after the browser credential API
# resolves. Accepts a +label+ string or a block for button content.
#
# Options:
# - +param+: the form parameter namespace (default: +:passkey+)
# - +form+: additional HTML attributes for the +<form>+ tag
# - All other options are passed to the +<button>+ tag
def passkey_creation_button(name = nil, url = nil, param: :passkey, form: {}, **options, &block)
url, name = name, block ? capture(&block) : nil if block_given?
form_options = form.reverse_merge(method: :post, action: url, class: "button_to")
tag.form(**form_options) do
hidden_field_tag(:authenticity_token, form_authenticity_token) +
hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
hidden_field_tag("#{param}[attestation_object]", nil, id: nil, data: { passkey_field: "attestation_object" }) +
hidden_field_tag("#{param}[transports][]", nil, id: nil, data: { passkey_field: "transports" }) +
tag.button(name, type: :button, data: { passkey: "create" }, **options)
end
end
# Renders a form with hidden fields for the passkey authentication ceremony. The form POSTs to
# +url+ and includes hidden fields for +id+, +client_data_json+, +authenticator_data+, and
# +signature+
# Accepts a +label+ string or a block for button content.
#
# Options:
# - +param+: the form parameter namespace (default: +:passkey+)
# - +mediation+: WebAuthn mediation hint (e.g. +"conditional"+ for autofill-assisted sign in)
# - +form+: additional HTML attributes for the +<form>+ tag
# - All other options are passed to the +<button>+ tag
def passkey_sign_in_button(name = nil, url = nil, param: :passkey, mediation: nil, form: {}, **options, &block)
url, name = name, block ? capture(&block) : nil if block_given?
form_data = {}
form_data[:passkey_mediation] = mediation if mediation
form_options = form.reverse_merge(method: :post, action: url, class: "button_to", data: form_data)
tag.form(**form_options) do
hidden_field_tag(:authenticity_token, form_authenticity_token) +
hidden_field_tag("#{param}[id]", nil, id: nil, data: { passkey_field: "id" }) +
hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
hidden_field_tag("#{param}[authenticator_data]", nil, id: nil, data: { passkey_field: "authenticator_data" }) +
hidden_field_tag("#{param}[signature]", nil, id: nil, data: { passkey_field: "signature" }) +
tag.button(name, type: :button, data: { passkey: "sign_in" }, **options)
end
end
private
def passkey_challenge_url_meta_tag(challenge_url: nil)
tag.meta(name: "passkey-challenge-url", content: challenge_url || default_passkey_challenge_url)
end
def default_passkey_challenge_url
if challenge_url = Rails.configuration.action_pack.passkey.challenge_url
instance_exec(&challenge_url)
else
passkey_challenge_path
end
end
end
+143
View File
@@ -0,0 +1,143 @@
# Adds passkey support to an Active Record model (the "holder" of passkeys).
#
# == Usage
#
# class User < ApplicationRecord
# has_passkeys name: :email_address, display_name: :name
# end
#
# This sets up a polymorphic +has_many :passkeys+ association and defines two methods on the
# model that supply holder-specific options for the WebAuthn ceremonies:
#
# - +passkey_creation_options+ — merged into ActionPack::Passkey.creation_options
# - +passkey_request_options+ — merged into ActionPack::Passkey.request_options
#
# == Options
#
# +has_passkeys+ accepts keyword arguments that map to WebAuthn creation or request option
# fields. Values can be symbols (sent to the record), procs (evaluated in the record's context),
# or plain values:
#
# [+name+]
# A human-readable account identifier (typically an email or username) shown by the
# authenticator when the user selects a passkey. Maps to the WebAuthn +user.name+ field.
#
# [+display_name+]
# A friendly label for the user (typically their full name) shown by the authenticator
# during passkey registration. Maps to the WebAuthn +user.displayName+ field.
#
# has_passkeys name: :email, display_name: :name
#
# For more complex configuration, pass a block that receives a ActionPack::Passkey::Holder::Config:
#
# has_passkeys do |config|
# config.creation_options { { name: email, display_name: name } }
# config.request_options { { user_verification: "required" } }
# end
module ActionPack::Passkey::Holder
extend ActiveSupport::Concern
class_methods do
# Declares that this model can hold passkeys. Sets up a polymorphic +has_many+ association
# and defines +passkey_creation_options+ and +passkey_request_options+ instance methods used
# by ActionPack::Passkey to build ceremony options.
#
# Keyword arguments matching CreationOptions or RequestOptions fields are extracted and
# turned into holder-scoped option procs automatically. An optional block yields a Config
# for more complex setup.
def has_passkeys(**options, &block)
config = Config.new(**options)
block&.call(config)
has_many config.association_name,
as: :holder,
dependent: config.dependent,
class_name: "ActionPack::Passkey"
define_method(:passkey_creation_options) do
{
id: id,
exclude_credentials: public_send(config.association_name)
}.merge(config.evaluate_creation_options(self))
end
define_method(:passkey_request_options) do
{ credentials: public_send(config.association_name) }.merge(config.evaluate_request_options(self))
end
end
end
# Configuration object yielded by +has_passkeys+ when a block is given. Allows setting
# custom association options and ceremony option blocks.
class Config
attr_accessor :association_name, :dependent
def initialize(**options)
@association_name = options.delete(:association_name) || :passkeys
@dependent = options.delete(:dependent) || :destroy
if creation_opts = extract_options_for(ActionPack::WebAuthn::PublicKeyCredential::CreationOptions, options)
@creation_options = options_to_proc(creation_opts)
end
if request_opts = extract_options_for(ActionPack::WebAuthn::PublicKeyCredential::RequestOptions, options)
@request_options = options_to_proc(request_opts)
end
end
# Sets a block to evaluate in the holder's context to produce additional request options.
#
# config.request_options { { user_verification: "required" } }
def request_options(&block)
@request_options = block
end
# Sets a block to evaluate in the holder's context to produce additional creation options.
#
# config.creation_options { { name: email, display_name: name } }
def creation_options(&block)
@creation_options = block
end
# Evaluates the request options block (if any) in the context of the given +record+. Called
# internally by the +passkey_request_options+ method defined on the holder.
def evaluate_request_options(record)
if @request_options
record.instance_exec(&@request_options)
else
{}
end
end
# Evaluates the creation options block (if any) in the context of the given +record+. Called
# internally by the +passkey_creation_options+ method defined on the holder.
def evaluate_creation_options(record)
if @creation_options
record.instance_exec(&@creation_options)
else
{}
end
end
private
def extract_options_for(klass, options)
keys = klass.attribute_names.map(&:to_sym)
extracted = options.slice(*keys)
options.except!(*keys)
extracted if extracted.any?
end
def options_to_proc(options)
proc do
options.transform_values do |value|
case value
when Symbol then send(value)
when Proc then instance_exec(&value)
else value
end
end
end
end
end
end
+81
View File
@@ -0,0 +1,81 @@
# = Action Pack Passkey Request
#
# Controller concern that sets up the WebAuthn request context and provides
# helper methods for passkey registration and authentication. Include this
# in any controller that handles passkey form submissions.
#
# == Registration example
#
# class PasskeysController < ApplicationController
# include ActionPack::Passkey::Request
#
# def new
# @creation_options = passkey_creation_options(holder: Current.user)
# end
#
# def create
# @passkey = ActionPack::Passkey.register(
# passkey_creation_params, holder: Current.user
# )
# redirect_to settings_path
# end
# end
#
# == Authentication example
#
# class SessionsController < ApplicationController
# include ActionPack::Passkey::Request
#
# def new
# @request_options = passkey_request_options
# end
#
# def create
# if passkey = ActionPack::Passkey.authenticate(passkey_request_params)
# sign_in passkey.holder
# redirect_to root_path
# else
# redirect_to new_session_path, alert: "Authentication failed"
# end
# end
# end
#
# == Before Action
#
# Automatically populates +ActionPack::WebAuthn::Current+ with the request
# host, origin, and challenge (read from the encrypted cookie set by
# ChallengesController). The cookie is deleted after being read to prevent
# replay.
#
module ActionPack::Passkey::Request
extend ActiveSupport::Concern
included do
before_action do
ActionPack::WebAuthn::Current.host = request.host
ActionPack::WebAuthn::Current.origin = request.base_url
ActionPack::WebAuthn::Current.challenge = cookies.encrypted[ActionPack::Passkey::ChallengesController::COOKIE_NAME]
cookies.delete(ActionPack::Passkey::ChallengesController::COOKIE_NAME)
end
end
# Returns strong parameters for the passkey registration ceremony.
def passkey_creation_params(param: :passkey)
params.expect(param => [ :client_data_json, :attestation_object, transports: [] ])
end
# Returns strong parameters for the passkey authentication ceremony.
def passkey_request_params(param: :passkey)
params.expect(param => [ :id, :client_data_json, :authenticator_data, :signature ])
end
# Returns RequestOptions for the authentication ceremony.
def passkey_request_options(**options)
ActionPack::Passkey.request_options(**options)
end
# Returns CreationOptions for the registration ceremony.
def passkey_creation_options(**options)
ActionPack::Passkey.creation_options(**options)
end
end
+70
View File
@@ -0,0 +1,70 @@
require_relative "web_authn"
# = Action Pack Railtie
#
# Integrates the WebAuthn and Passkey subsystems into the Rails application.
# Configures default options for WebAuthn ceremonies and sets up the passkey
# challenge endpoint route.
#
# == Configuration
#
# # config/application.rb or config/initializers/passkeys.rb
# config.action_pack.web_authn.default_creation_options = { attestation: :none }
# config.action_pack.web_authn.default_request_options = { user_verification: :required }
# config.action_pack.web_authn.creation_challenge_expiration = 10.minutes
# config.action_pack.web_authn.request_challenge_expiration = 5.minutes
#
# config.action_pack.passkey.routes_prefix = "/rails/action_pack/passkey"
# config.action_pack.passkey.draw_routes = true
#
class ActionPack::Railtie < Rails::Railtie
config.action_pack = ActiveSupport::OrderedOptions.new unless config.respond_to?(:action_pack)
config.action_pack.web_authn = ActiveSupport::OrderedOptions.new
config.action_pack.web_authn.default_request_options = {}
config.action_pack.web_authn.default_creation_options = {}
config.action_pack.web_authn.creation_challenge_expiration = 10.minutes
config.action_pack.web_authn.request_challenge_expiration = 5.minutes
config.action_pack.passkey = ActiveSupport::OrderedOptions.new
config.action_pack.passkey.parent_class_name = "ApplicationRecord"
config.action_pack.passkey.routes_prefix = "/rails/action_pack/passkey"
config.action_pack.passkey.draw_routes = true
config.action_pack.passkey.challenge_url = nil
initializer "action_pack.passkey.routes" do |app|
passkey_config = config.action_pack.passkey
app.routes.prepend do
if passkey_config.draw_routes
scope passkey_config.routes_prefix, as: :passkey do
post "/challenge" => "action_pack/passkey/challenges#create", as: :challenge
end
end
end
end
initializer "action_pack.passkey.holder" do
ActiveSupport.on_load(:active_record) do
# We need this shim because Holder is namespaced under Passkey, which is an ActiveRecord
# and can't be required before ActiveRecord is loaded.
def self.has_passkeys(**options, &block)
include ActionPack::Passkey::Holder
has_passkeys(**options, &block)
end
end
end
initializer "action_pack.passkey.form_helper" do
ActiveSupport.on_load(:action_view) do
require_relative "passkey/form_helper"
include ActionPack::Passkey::FormHelper
end
end
initializer "action_pack.passkey.request" do
ActiveSupport.on_load(:action_controller) do
require_relative "passkey/request"
end
end
end
+64
View File
@@ -0,0 +1,64 @@
# = Action Pack WebAuthn
#
# Provides a pure-Ruby implementation of the WebAuthn (Web Authentication)
# specification for passkey registration and authentication. This module
# is the top-level namespace for all WebAuthn components and provides
# shared utilities used across ceremonies.
#
# == Components
#
# [ActionPack::WebAuthn::RelyingParty]
# Identifies your application to authenticators.
#
# [ActionPack::WebAuthn::PublicKeyCredential]
# Orchestrates registration and authentication ceremonies.
#
# [ActionPack::WebAuthn::Authenticator]
# Parses and validates authenticator responses.
#
# [ActionPack::WebAuthn::CborDecoder]
# Decodes CBOR-encoded data from authenticators.
#
# [ActionPack::WebAuthn::CoseKey]
# Parses COSE public keys into OpenSSL key objects.
#
# == Extending Attestation Formats
#
# By default only the "none" attestation format is supported. Register
# additional verifiers with:
#
# ActionPack::WebAuthn.register_attestation_verifier("packed", MyPackedVerifier.new)
#
module ActionPack::WebAuthn
class InvalidResponseError < StandardError; end
class InvalidCborError < StandardError; end
class InvalidKeyError < StandardError; end
class UnsupportedKeyTypeError < StandardError; end
class InvalidOptionsError < StandardError; end
class << self
# Returns a new RelyingParty configured from the current request context.
def relying_party
RelyingParty.new
end
# Returns the MessageVerifier used to sign and verify WebAuthn challenges.
def challenge_verifier
Rails.application.message_verifier("action_pack.webauthn.challenge")
end
# Returns the registry of attestation format verifiers, keyed by format
# string (e.g., "none", "packed"). Only "none" is registered by default.
def attestation_verifiers
@attestation_verifiers ||= {
"none" => Authenticator::AttestationVerifiers::None.new
}
end
# Registers a custom attestation verifier for the given +format+.
# The +verifier+ must respond to +verify!(attestation, client_data_json:)+.
def register_attestation_verifier(format, verifier)
attestation_verifiers[format.to_s] = verifier
end
end
end
@@ -0,0 +1,85 @@
# = Action Pack WebAuthn Assertion Response
#
# Handles the authenticator response from a WebAuthn authentication ceremony.
# When a user authenticates with an existing credential, the authenticator
# returns an assertion response containing a signature that proves possession
# of the private key.
#
# == Usage
#
# # Look up the credential by ID
# credential = user.credentials.find_by!(
# credential_id: params[:id]
# )
#
# response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
# client_data_json: params[:response][:clientDataJSON],
# authenticator_data: params[:response][:authenticatorData],
# signature: params[:response][:signature],
# credential: credential.to_public_key_credential,
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: "https://example.com"
# )
#
# response.validate!
#
# == Validation
#
# In addition to the base Response validations, this class verifies:
#
# * The client data type is "webauthn.get"
# * The signature is valid for the credential's public key
#
class ActionPack::WebAuthn::Authenticator::AssertionResponse < ActionPack::WebAuthn::Authenticator::Response
attr_reader :credential, :authenticator_data, :signature
validate :client_data_type_must_be_get
validate :signature_must_be_valid
validate :sign_count_must_increase
def initialize(credential:, authenticator_data:, signature:, **attributes)
super(**attributes)
@credential = credential
@signature = signature
@signature = Base64.urlsafe_decode64(@signature) unless @signature.encoding == Encoding::BINARY
@authenticator_data = ActionPack::WebAuthn::Authenticator::Data.wrap(authenticator_data)
rescue ArgumentError
raise ActionPack::WebAuthn::InvalidResponseError, "Invalid base64 encoding in signature"
end
private
def client_data_type_must_be_get
unless client_data["type"] == "webauthn.get"
errors.add(:base, "Client data type is not webauthn.get")
end
end
def signature_must_be_valid
client_data_hash = Digest::SHA256.digest(client_data_json)
signed_data = authenticator_data.bytes.pack("C*") + client_data_hash
digest = credential.public_key.oid == "ED25519" ? nil : "SHA256"
unless credential.public_key.verify(digest, signature, signed_data)
errors.add(:base, "Invalid signature")
end
rescue OpenSSL::PKey::PKeyError
errors.add(:base, "Invalid signature")
end
def sign_count_must_increase
unless sign_count_increased?
errors.add(:base, "Sign count did not increase")
end
end
def sign_count_increased?
if authenticator_data.sign_count.zero? && credential.sign_count.zero?
# Some authenticators always return 0 for the sign count, even after multiple authentications.
# In that case, we have to check that both the stored and returned sign counts are 0,
# which indicates that the authenticator is likely not updating the sign count.
true
else
authenticator_data.sign_count > credential.sign_count
end
end
end
@@ -0,0 +1,73 @@
# = Action Pack WebAuthn Attestation
#
# Decodes and represents the attestation object returned by an authenticator
# during registration. The attestation object is CBOR-encoded and contains
# the authenticator data along with an optional attestation statement.
#
# == Usage
#
# attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(
# attestation_object_bytes
# )
#
# attestation.credential_id # => "abc123..."
# attestation.public_key # => OpenSSL::PKey::EC
# attestation.sign_count # => 0
#
# == Attributes
#
# [+authenticator_data+]
# The parsed Data containing credential information.
#
# [+format+]
# The attestation statement format (e.g., "none", "packed", "fido-u2f").
#
# [+attestation_statement+]
# The attestation statement, which may contain a signature from the
# authenticator manufacturer. Empty for "none" format.
#
# == Delegated Methods
#
# The following methods are delegated to +authenticator_data+:
#
# * +credential_id+ - Base64URL-encoded credential identifier
# * +public_key+ - OpenSSL public key object
# * +public_key_bytes+ - Raw COSE key bytes
# * +sign_count+ - Signature counter for replay detection
#
class ActionPack::WebAuthn::Authenticator::Attestation
attr_reader :authenticator_data, :format, :attestation_statement
delegate :credential_id, :public_key, :public_key_bytes, :sign_count, :aaguid, :backed_up?, to: :authenticator_data
# Wraps raw attestation data into an Attestation instance. Accepts an
# existing Attestation object (returned as-is), a Base64URL-encoded string,
# or raw binary.
def self.wrap(data)
if data.is_a?(self)
data
else
data = Base64.urlsafe_decode64(data) unless data.encoding == Encoding::BINARY
decode(data)
end
rescue ArgumentError
raise ActionPack::WebAuthn::InvalidResponseError, "Invalid base64 encoding in attestation object"
end
# Decodes a CBOR-encoded attestation object into an Attestation instance.
def self.decode(bytes)
cbor = ActionPack::WebAuthn::CborDecoder.decode(bytes)
new(
authenticator_data: ActionPack::WebAuthn::Authenticator::Data.decode(cbor["authData"]),
format: cbor["fmt"],
attestation_statement: cbor["attStmt"]
)
end
def initialize(authenticator_data:, format:, attestation_statement:)
@authenticator_data = authenticator_data
@format = format
@attestation_statement = attestation_statement
end
end
@@ -0,0 +1,68 @@
# = Action Pack WebAuthn Attestation Response
#
# Handles the authenticator response from a WebAuthn registration ceremony.
# When a user registers a new credential, the authenticator returns an
# attestation response containing the new public key and credential ID.
#
# == Usage
#
# response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
# client_data_json: params[:response][:clientDataJSON],
# attestation_object: params[:response][:attestationObject],
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: "https://example.com"
# )
#
# response.validate!
#
# # Store the credential
# credential_id = response.attestation.credential_id
# public_key = response.attestation.public_key
#
# == Validation
#
# In addition to the base Response validations, this class verifies:
#
# * The client data type is "webauthn.create"
# * The attestation format has a registered verifier
# * The attestation statement passes format-specific verification
#
class ActionPack::WebAuthn::Authenticator::AttestationResponse < ActionPack::WebAuthn::Authenticator::Response
attr_reader :attestation_object
validate :client_data_type_must_be_create
validate :attestation_must_be_valid
def initialize(attestation_object:, **attributes)
super(**attributes)
@attestation_object = attestation_object
end
# Returns the decoded Attestation object, lazily parsed from the raw
# attestation object bytes.
def attestation
@attestation ||= ActionPack::WebAuthn::Authenticator::Attestation.wrap(attestation_object)
end
# Returns the authenticator data extracted from the attestation object.
def authenticator_data
attestation.authenticator_data
end
private
def client_data_type_must_be_create
unless client_data["type"] == "webauthn.create"
errors.add(:base, "Client data type is not webauthn.create")
end
end
def attestation_must_be_valid
verifier = ActionPack::WebAuthn.attestation_verifiers[attestation.format]
if verifier
verifier.verify!(attestation, client_data_json: client_data_json)
else
errors.add(:base, "Unsupported attestation format: #{attestation.format}")
end
end
end
@@ -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::InvalidResponseError,
"Attestation statement must be empty for 'none' format"
end
end
end
@@ -0,0 +1,174 @@
# = Action Pack WebAuthn Authenticator Data
#
# Decodes and represents the authenticator data structure from WebAuthn
# responses. This binary format contains information about the authenticator
# and, during registration, the newly created credential.
#
# == Structure
#
# The authenticator data consists of:
#
# * RP ID Hash (32 bytes) - SHA-256 hash of the relying party ID
# * Flags (1 byte) - Bit flags for user presence, verification, etc.
# * Sign Count (4 bytes) - Signature counter for replay detection
# * Attested Credential Data (variable) - Present only during registration
#
# == Usage
#
# data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes)
#
# data.user_present? # => true
# data.user_verified? # => true
# data.sign_count # => 42
# data.credential_id # => "abc123..." (registration only)
# data.public_key # => OpenSSL::PKey::EC (registration only)
#
# == Flags
#
# [+user_present?+]
# Returns true if the user performed a test of user presence (e.g., touched
# the authenticator).
#
# [+user_verified?+]
# Returns true if the user was verified through biometrics, PIN, or other
# method. This is stronger than mere presence.
#
# [+backup_eligible?+]
# Returns true if the credential can be backed up (e.g., synced passkeys
# from Apple, Google, or Microsoft). Indicates multi-device credential support.
#
# [+backed_up?+]
# Returns true if the credential is currently backed up to cloud storage.
# Useful for risk assessment—backed-up credentials may be accessible from
# multiple devices.
#
class ActionPack::WebAuthn::Authenticator::Data
# Segment lengths
RELYING_PARTY_ID_HASH_LENGTH = 32
FLAGS_LENGTH = 1
SIGN_COUNT_LENGTH = 4
AAGUID_LENGTH = 16
CREDENTIAL_ID_LENGTH_BYTES = 2
# Flags
USER_PRESENT_FLAG = 0x01
USER_VERIFIED_FLAG = 0x04
BACKUP_ELIGIBLE_FLAG = 0x08
BACKUP_STATE_FLAG = 0x10
ATTESTED_CREDENTIAL_DATA_FLAG = 0x40
attr_reader :bytes, :relying_party_id_hash, :flags, :sign_count, :aaguid, :credential_id, :public_key_bytes
class << self
# Wraps raw authenticator data into a Data instance. Accepts an existing
# Data object (returned as-is), a Base64URL-encoded string, or raw binary.
def wrap(data)
if data.is_a?(self)
data
else
data = Base64.urlsafe_decode64(data) unless data.encoding == Encoding::BINARY
decode(data)
end
rescue ArgumentError
raise ActionPack::WebAuthn::InvalidResponseError, "Invalid base64 encoding in authenticator data"
end
# Decodes raw authenticator data bytes into a Data instance, parsing the
# RP ID hash, flags, sign count, and (if present) attested credential data.
def decode(bytes)
bytes = bytes.bytes if bytes.is_a?(String)
minimum_length = RELYING_PARTY_ID_HASH_LENGTH + FLAGS_LENGTH + SIGN_COUNT_LENGTH
if bytes.length < minimum_length
raise ActionPack::WebAuthn::InvalidResponseError, "Authenticator data is too short"
end
position = 0
relying_party_id_hash = bytes[position, RELYING_PARTY_ID_HASH_LENGTH].pack("C*")
position += RELYING_PARTY_ID_HASH_LENGTH
flags = bytes[position]
position += FLAGS_LENGTH
sign_count = bytes[position, SIGN_COUNT_LENGTH].pack("C*").unpack1("N")
position += SIGN_COUNT_LENGTH
aaguid = nil
credential_id = nil
public_key_bytes = nil
if flags & ATTESTED_CREDENTIAL_DATA_FLAG != 0
if bytes.length < position + AAGUID_LENGTH + CREDENTIAL_ID_LENGTH_BYTES
raise ActionPack::WebAuthn::InvalidResponseError, "Authenticator data is too short for attested credential data"
end
aaguid_bytes = bytes[position, AAGUID_LENGTH].pack("C*")
aaguid = aaguid_bytes.unpack("H8H4H4H4H12").join("-")
position += AAGUID_LENGTH
credential_id_length = bytes[position, CREDENTIAL_ID_LENGTH_BYTES].pack("C*").unpack1("n")
position += CREDENTIAL_ID_LENGTH_BYTES
if bytes.length < position + credential_id_length + 1
raise ActionPack::WebAuthn::InvalidResponseError, "Authenticator data is too short for credential ID and public key"
end
credential_id = Base64.urlsafe_encode64(bytes[position, credential_id_length].pack("C*"), padding: false)
position += credential_id_length
public_key_bytes = bytes[position..].pack("C*")
end
new(
bytes: bytes,
relying_party_id_hash: relying_party_id_hash,
flags: flags,
sign_count: sign_count,
aaguid: aaguid,
credential_id: credential_id,
public_key_bytes: public_key_bytes
)
end
end
def initialize(bytes:, relying_party_id_hash:, flags:, sign_count:, aaguid: nil, credential_id:, public_key_bytes:)
@bytes = bytes
@relying_party_id_hash = relying_party_id_hash
@flags = flags
@sign_count = sign_count
@aaguid = aaguid
@credential_id = credential_id
@public_key_bytes = public_key_bytes
end
# Returns true if the user performed a test of presence (e.g., touched the
# authenticator).
def user_present?
flags & USER_PRESENT_FLAG != 0
end
# Returns true if the user was verified via biometrics, PIN, or similar.
def user_verified?
flags & USER_VERIFIED_FLAG != 0
end
# Returns true if the credential is eligible for backup (e.g., synced passkey).
# This indicates the authenticator supports multi-device credentials.
def backup_eligible?
flags & BACKUP_ELIGIBLE_FLAG != 0
end
# Returns true if the credential is currently backed up to cloud storage.
# Only meaningful when +backup_eligible?+ is true.
def backed_up?
flags & BACKUP_STATE_FLAG != 0
end
# Decodes the COSE public key bytes into an OpenSSL key object.
# Returns +nil+ when no attested credential data is present (authentication
# responses).
def public_key
@public_key ||= ActionPack::WebAuthn::CoseKey.decode(public_key_bytes).to_openssl_key if public_key_bytes
end
end
@@ -0,0 +1,143 @@
# = Action Pack WebAuthn Authenticator Response
#
# Abstract base class for WebAuthn authenticator responses. Provides common
# validation logic for both registration (attestation) and authentication
# (assertion) ceremonies.
#
# This class should not be instantiated directly. Use AttestationResponse for
# registration or AssertionResponse for authentication.
#
# == Validation
#
# The +validate!+ method performs security checks required by the WebAuthn
# specification:
#
# * Challenge verification - ensures the response matches the server-generated challenge
# * Origin verification - ensures the response comes from the expected origin
# * User verification - optionally requires biometric or PIN verification
#
# == Example
#
# response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
# client_data_json: client_data_json,
# authenticator_data: authenticator_data,
# signature: signature,
# credential: credential,
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: "https://example.com",
# user_verification: :required
# )
#
# response.validate!
#
class ActionPack::WebAuthn::Authenticator::Response
include ActiveModel::Validations
attr_reader :client_data_json
attr_accessor :challenge, :origin, :user_verification
validate :challenge_must_match
validate :challenge_must_not_be_expired
validate :origin_must_match
validate :must_not_be_cross_origin
validate :must_not_have_token_binding
validate :relying_party_id_must_match
validate :user_must_be_present
validate :user_must_be_verified_when_required
def initialize(client_data_json:, challenge: nil, origin: nil, user_verification: :preferred)
@client_data_json = client_data_json
@challenge = challenge
@origin = origin
@user_verification = user_verification.to_sym
end
def validate!
super
rescue ActiveModel::ValidationError
raise ActionPack::WebAuthn::InvalidResponseError, errors.full_messages.join(", ")
end
# Returns the RelyingParty used for RP ID validation.
def relying_party
ActionPack::WebAuthn.relying_party
end
# Parses the client data JSON string into a Hash. Raises
# +InvalidResponseError+ if the JSON is malformed.
def client_data
@client_data ||= JSON.parse(client_data_json)
rescue JSON::ParserError
raise ActionPack::WebAuthn::InvalidResponseError, "Client data is not valid JSON"
end
def authenticator_data
nil
end
private
def challenge_must_match
if challenge.blank?
errors.add(:base, "Challenge missing")
elsif client_data["challenge"].blank?
errors.add(:base, "Challenge missing in client data")
elsif !ActiveSupport::SecurityUtils.secure_compare(challenge.to_s, client_data["challenge"].to_s)
errors.add(:base, "Challenge does not match")
end
end
def challenge_must_not_be_expired
return if errors.any? || challenge.blank?
signed_message = Base64.urlsafe_decode64(challenge)
unless ActionPack::WebAuthn.challenge_verifier.verified(signed_message)
errors.add(:base, "Challenge has expired")
end
rescue ArgumentError
errors.add(:base, "Challenge is invalid")
end
def origin_must_match
if origin.blank?
errors.add(:base, "Origin missing")
elsif client_data["origin"].blank?
errors.add(:base, "Origin missing in client data")
elsif !ActiveSupport::SecurityUtils.secure_compare(origin.to_s, client_data["origin"].to_s)
errors.add(:base, "Origin does not match")
end
end
def must_not_be_cross_origin
if client_data["crossOrigin"] == true
errors.add(:base, "Cross-origin requests are not supported")
end
end
def must_not_have_token_binding
if client_data.dig("tokenBinding", "status") == "present"
errors.add(:base, "Token binding is not supported")
end
end
def relying_party_id_must_match
unless ActiveSupport::SecurityUtils.secure_compare(
Digest::SHA256.digest(relying_party.id),
authenticator_data&.relying_party_id_hash || ""
)
errors.add(:base, "Relying party ID does not match")
end
end
def user_must_be_present
unless authenticator_data&.user_present?
errors.add(:base, "User presence is required")
end
end
def user_must_be_verified_when_required
if user_verification == :required && !authenticator_data&.user_verified?
errors.add(:base, "User verification is required")
end
end
end
+269
View File
@@ -0,0 +1,269 @@
# = Action Pack WebAuthn CBOR Decoder
#
# Decodes Concise Binary Object Representation (CBOR) data as specified in
# RFC 8949[https://tools.ietf.org/html/rfc8949]. CBOR is a binary data format
# used by WebAuthn for encoding authenticator data and attestation objects.
#
# == Usage
#
# The decoder accepts either a binary string or an array of bytes:
#
# # From binary string
# ActionPack::WebAuthn::CborDecoder.decode("\x83\x01\x02\x03")
# # => [1, 2, 3]
#
# # From byte array
# ActionPack::WebAuthn::CborDecoder.decode([0x83, 0x01, 0x02, 0x03])
# # => [1, 2, 3]
#
# == Supported Types
#
# The decoder supports the following CBOR types:
#
# [Integers]
# Unsigned (major type 0) and negative (major type 1) integers of any size.
#
# [Byte strings]
# Binary data (major type 2), returned as ASCII-8BIT encoded strings.
#
# [Text strings]
# UTF-8 text (major type 3), returned as UTF-8 encoded strings.
#
# [Arrays]
# Ordered collections (major type 4) of any CBOR values.
#
# [Maps]
# Key-value pairs (major type 5) with any CBOR values as keys and values.
#
# [Floats]
# IEEE 754 half (16-bit), single (32-bit), and double (64-bit) precision.
#
# [Simple values]
# +false+, +true+, +null+, and +undefined+ (both decoded as +nil+).
#
# [Indefinite length]
# Streaming byte strings, text strings, arrays, and maps.
#
# Tags (major type 6) are recognized but their semantic meaning is ignored;
# the tagged value is returned directly.
#
# == Errors
#
# Raises +InvalidCborError+ when encountering malformed or unsupported CBOR data.
class ActionPack::WebAuthn::CborDecoder
# Major types
UNSIGNED_INTEGER_TYPE = 0
NEGATIVE_INTEGER_TYPE = 1
BYTE_STRING_TYPE = 2
TEXT_STRING_TYPE = 3
ARRAY_TYPE = 4
MAP_TYPE = 5
TAG_TYPE = 6
FLOAT_OR_SIMPLE_TYPE = 7
# Additional information values
SIMPLE_VALUE_RANGE = 0..23
SINGLE_BYTE_VALUE_FOLLOWS = 24
TWO_BYTE_VALUE_FOLLOWS = 25
FOUR_BYTE_VALUE_FOLLOWS = 26
EIGHT_BYTE_VALUE_FOLLOWS = 27
RESERVED_VALUE_RANGE = 28..30
INDEFINITE_LENGTH_MAJOR_TYPE = 31
# Simple values
SIMPLE_FALSE_VALUE = 20
SIMPLE_TRUE_VALUE = 21
SIMPLE_NULL_VALUE = 22
SIMPLE_UNDEFINED_VALUE = 23
# Flow control
BREAK_CODE = 0xFF
# Limits
MAX_DEPTH = 16
MAX_SIZE = 10.megabytes
# Tags
POSITIVE_BIGNUM_TAG = 2
NEGATIVE_BIGNUM_TAG = 3
class << self
# Decodes a CBOR-encoded byte sequence into a Ruby object.
#
# ActionPack::WebAuthn::CborDecoder.decode("\xa2\x61a\x01\x61b\x02")
# # => {"a" => 1, "b" => 2}
def decode(bytes, **args)
bytes = bytes.bytes if bytes.respond_to?(:bytes)
new(bytes, **args).decode
end
end
def initialize(bytes, max_depth: MAX_DEPTH, max_size: MAX_SIZE) # :nodoc:
raise ActionPack::WebAuthn::InvalidCborError, "Input exceeds maximum size" if bytes.length > max_size
@bytes = bytes
@max_depth = max_depth
@position = 0
@depth = 0
end
# Decodes the next CBOR data item from the byte sequence.
def decode
raise ActionPack::WebAuthn::InvalidCborError, "Unexpected end of input" if @position >= @bytes.length
raise ActionPack::WebAuthn::InvalidCborError, "Maximum nesting depth exceeded" if @depth >= @max_depth
@depth += 1
result = case major_type
when UNSIGNED_INTEGER_TYPE then decode_unsigned_integer
when NEGATIVE_INTEGER_TYPE then decode_negative_integer
when BYTE_STRING_TYPE then decode_byte_string
when TEXT_STRING_TYPE then decode_text_string
when ARRAY_TYPE then decode_array
when MAP_TYPE then decode_map
when TAG_TYPE then decode_tag
when FLOAT_OR_SIMPLE_TYPE then decode_float_or_simple
end
@depth -= 1
result
end
private
def major_type
peek >> 5
end
def peek
@bytes[@position]
end
def decode_unsigned_integer
read_argument
end
def decode_negative_integer
-1 - read_argument
end
def decode_byte_string
if indefinite_length?
String.new(encoding: Encoding::ASCII_8BIT).tap { |str| str << decode_byte_string until break_code? }
else
read_bytes(read_argument).pack("C*")
end
end
def decode_text_string
if indefinite_length?
String.new(encoding: Encoding::UTF_8).tap { |str| str << decode_text_string until break_code? }
else
read_bytes(read_argument).pack("C*").force_encoding(Encoding::UTF_8)
end
end
def decode_array
if indefinite_length?
Array.new.tap { |arr| arr << decode until break_code? }
else
Array.new(read_argument) { decode }
end
end
def decode_map
if indefinite_length?
Hash.new.tap { |hash| hash[decode] = decode until break_code? }
else
Hash.new.tap do |hash|
read_argument.times do
hash[decode] = decode
end
end
end
end
def decode_float_or_simple
case info = additional_info
when SIMPLE_FALSE_VALUE then false
when SIMPLE_TRUE_VALUE then true
when SIMPLE_NULL_VALUE, SIMPLE_UNDEFINED_VALUE then nil
when TWO_BYTE_VALUE_FOLLOWS then decode_half_float
when FOUR_BYTE_VALUE_FOLLOWS then read_bytes(4).pack("C*").unpack1("g")
when EIGHT_BYTE_VALUE_FOLLOWS then read_bytes(8).pack("C*").unpack1("G")
else
raise ActionPack::WebAuthn::InvalidCborError, "Invalid simple value: #{info}"
end
end
def decode_tag
tag = read_argument
value = decode
case tag
when POSITIVE_BIGNUM_TAG then value.bytes.inject(0) { |n, b| (n << 8) | b }
when NEGATIVE_BIGNUM_TAG then -1 - value.bytes.inject(0) { |n, b| (n << 8) | b }
else value
end
end
def decode_half_float
half = read_bytes(2).pack("C*").unpack1("n")
sign = (half >> 15) & 0x1
exponent = (half >> 10) & 0x1F
mantissa = half & 0x3FF
value = if exponent == 0
Math.ldexp(mantissa, -24)
elsif exponent == 31
mantissa == 0 ? Float::INFINITY : Float::NAN
else
Math.ldexp(mantissa + 1024, exponent - 25)
end
sign == 1 ? -value : value
end
def read_argument
case info = additional_info
when SIMPLE_VALUE_RANGE then info
when SINGLE_BYTE_VALUE_FOLLOWS then read_byte
when TWO_BYTE_VALUE_FOLLOWS then read_bytes(2).pack("C*").unpack1("n")
when FOUR_BYTE_VALUE_FOLLOWS then read_bytes(4).pack("C*").unpack1("N")
when EIGHT_BYTE_VALUE_FOLLOWS then read_bytes(8).pack("C*").unpack1("Q>")
when RESERVED_VALUE_RANGE
raise ActionPack::WebAuthn::InvalidCborError, "Reserved additional info: #{info}"
else
raise ActionPack::WebAuthn::InvalidCborError, "Invalid additional info: #{info}"
end
end
def additional_info(consume: true)
byte = consume ? read_byte : peek
byte & 0b00011111
end
def indefinite_length?
read_byte if additional_info(consume: false) == INDEFINITE_LENGTH_MAJOR_TYPE
end
def break_code?
read_byte if peek == BREAK_CODE
end
def read_bytes(length)
raise ActionPack::WebAuthn::InvalidCborError, "Unexpected end of input" if @position + length > @bytes.length
bytes = @bytes[@position, length]
@position += length
bytes
end
def read_byte
raise ActionPack::WebAuthn::InvalidCborError, "Unexpected end of input" if @position >= @bytes.length
byte = @bytes[@position]
@position += 1
byte
end
end
+183
View File
@@ -0,0 +1,183 @@
# = Action Pack WebAuthn COSE Key
#
# Parses COSE (CBOR Object Signing and Encryption) public keys as specified in
# RFC 9053[https://datatracker.ietf.org/doc/html/rfc9053]. WebAuthn authenticators
# return public keys in COSE format, which must be converted to a standard format
# for signature verification.
#
# == Usage
#
# # Decode a COSE key from CBOR bytes (e.g., from authenticator data)
# cose_key = ActionPack::WebAuthn::CoseKey.decode(cbor_bytes)
#
# # Convert to OpenSSL key for signature verification
# openssl_key = cose_key.to_openssl_key
# openssl_key.verify("SHA256", signature, signed_data)
#
# == Supported Algorithms
#
# [ES256]
# ECDSA with P-256 curve and SHA-256. The most common algorithm for WebAuthn.
#
# [EdDSA]
# EdDSA with Ed25519 curve. Increasingly supported by modern authenticators.
#
# [RS256]
# RSASSA-PKCS1-v1_5 with SHA-256. Used by some security keys and platforms.
#
# == Attributes
#
# [+key_type+]
# The COSE key type (1 for OKP, 2 for EC2, 3 for RSA).
#
# [+algorithm+]
# The COSE algorithm identifier (-7 for ES256, -8 for EdDSA, -257 for RS256).
#
# [+parameters+]
# The full COSE key parameters map, including curve and coordinate data.
class ActionPack::WebAuthn::CoseKey
P256_COORDINATE_LENGTH = 32
MINIMUM_RSA_KEY_BITS = 2048
# COSE key labels
KEY_TYPE_LABEL = 1
ALGORITHM_LABEL = 3
EC2_CURVE_LABEL = -1
EC2_X_LABEL = -2
EC2_Y_LABEL = -3
RSA_N_LABEL = -1
RSA_E_LABEL = -2
OKP_CURVE_LABEL = -1
OKP_X_LABEL = -2
# COSE key types
OKP = 1
EC2 = 2
RSA = 3
# COSE algorithms
ES256 = -7
EDDSA = -8
RS256 = -257
# COSE EC2 curves
P256 = 1
# COSE OKP curves
ED25519 = 6
# OpenSSL types
UNCOMPRESSED_POINT_MARKER = 0x04
attr_reader :key_type, :algorithm, :parameters
class << self
# Decodes a COSE key from CBOR-encoded bytes.
#
# cose_key = ActionPack::WebAuthn::CoseKey.decode(cbor_bytes)
# cose_key.algorithm # => -7 (ES256)
def decode(bytes)
data = ActionPack::WebAuthn::CborDecoder.decode(bytes)
new(
key_type: data[KEY_TYPE_LABEL],
algorithm: data[ALGORITHM_LABEL],
parameters: data
)
end
end
def initialize(key_type:, algorithm:, parameters:) # :nodoc:
@key_type = key_type
@algorithm = algorithm
@parameters = parameters
end
# Converts the COSE key to an OpenSSL public key object.
#
# Returns an +OpenSSL::PKey::EC+ for EC2 keys, +OpenSSL::PKey::RSA+ for
# RSA keys, or an Ed25519 key for OKP keys, suitable for use with
# +OpenSSL::PKey#verify+.
#
# Raises +UnsupportedKeyTypeError+ if the key type, algorithm, or curve
# is not supported.
def to_openssl_key
case [ key_type, algorithm ]
when [ EC2, ES256 ] then build_ec2_es256_key
when [ OKP, EDDSA ] then build_okp_eddsa_key
when [ RSA, RS256 ] then build_rsa_rs256_key
else raise ActionPack::WebAuthn::UnsupportedKeyTypeError, "Unsupported COSE key type/algorithm: #{key_type}/#{algorithm}"
end
end
private
def build_ec2_es256_key
curve = parameters[EC2_CURVE_LABEL]
raise ActionPack::WebAuthn::UnsupportedKeyTypeError, "Unsupported EC curve: #{curve}" unless curve == P256
x = parameters[EC2_X_LABEL]
y = parameters[EC2_Y_LABEL]
raise ActionPack::WebAuthn::InvalidKeyError, "Missing EC2 key coordinates" if x.nil? || y.nil?
raise ActionPack::WebAuthn::InvalidKeyError, "Invalid EC2 coordinate length" unless x.bytesize == P256_COORDINATE_LENGTH && y.bytesize == P256_COORDINATE_LENGTH
# Uncompressed point format: 0x04 || x || y
public_key_bytes = [ UNCOMPRESSED_POINT_MARKER, *x.bytes, *y.bytes ].pack("C*")
asn1 = OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::ObjectId("id-ecPublicKey"),
OpenSSL::ASN1::ObjectId("prime256v1")
]),
OpenSSL::ASN1::BitString(public_key_bytes)
])
OpenSSL::PKey::EC.new(asn1.to_der)
rescue OpenSSL::PKey::PKeyError => error
raise ActionPack::WebAuthn::InvalidKeyError, "Invalid EC2 key: #{error.message}"
end
def build_okp_eddsa_key
curve = parameters[OKP_CURVE_LABEL]
raise ActionPack::WebAuthn::UnsupportedKeyTypeError, "Unsupported OKP curve: #{curve}" unless curve == ED25519
x = parameters[OKP_X_LABEL]
raise ActionPack::WebAuthn::InvalidKeyError, "Missing OKP key coordinate" if x.nil?
asn1 = OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::ObjectId("ED25519")
]),
OpenSSL::ASN1::BitString(x)
])
OpenSSL::PKey.read(asn1.to_der)
rescue OpenSSL::PKey::PKeyError => error
raise ActionPack::WebAuthn::InvalidKeyError, "Invalid OKP key: #{error.message}"
end
def build_rsa_rs256_key
n_bytes = parameters[RSA_N_LABEL]
e_bytes = parameters[RSA_E_LABEL]
raise ActionPack::WebAuthn::InvalidKeyError, "Missing RSA key parameters" if n_bytes.nil? || e_bytes.nil?
raise ActionPack::WebAuthn::InvalidKeyError, "RSA key must be at least #{MINIMUM_RSA_KEY_BITS} bits" if n_bytes.bytesize * 8 < MINIMUM_RSA_KEY_BITS
n = OpenSSL::BN.new(n_bytes, 2)
e = OpenSSL::BN.new(e_bytes, 2)
asn1 = OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::ObjectId("rsaEncryption"),
OpenSSL::ASN1::Null.new(nil)
]),
OpenSSL::ASN1::BitString(
OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::Integer(n),
OpenSSL::ASN1::Integer(e)
]).to_der
)
])
OpenSSL::PKey::RSA.new(asn1.to_der)
rescue OpenSSL::PKey::PKeyError => error
raise ActionPack::WebAuthn::InvalidKeyError, "Invalid RSA key: #{error.message}"
end
end
+23
View File
@@ -0,0 +1,23 @@
# = Action Pack WebAuthn Current Attributes
#
# Thread-isolated request-scoped attributes for WebAuthn ceremonies. These are
# set automatically by ActionPack::Passkey::Request at the start of each
# request and consumed by the registration/authentication flows.
#
# == Attributes
#
# [+host+]
# The relying party identifier (typically +request.host+). Used as the
# default RelyingParty ID.
#
# [+origin+]
# The expected origin (typically +request.base_url+). Validated against the
# +origin+ field in the authenticator's client data.
#
# [+challenge+]
# The Base64URL-encoded challenge read from the encrypted cookie. Validated
# against the +challenge+ field in the authenticator's client data.
#
class ActionPack::WebAuthn::Current < ActiveSupport::CurrentAttributes
attribute :host, :origin, :challenge
end
@@ -0,0 +1,156 @@
# = Action Pack WebAuthn Public Key Credential
#
# Represents a WebAuthn public key credential and orchestrates the registration
# and authentication ceremonies. During registration (+.register+), it verifies
# the attestation response and returns a new credential. During authentication
# (+#authenticate+), it verifies the assertion response against the stored
# public key.
#
# == Registration
#
# credential = ActionPack::WebAuthn::PublicKeyCredential.register(
# params[:passkey],
# challenge: ActionPack::WebAuthn::Current.challenge,
# origin: ActionPack::WebAuthn::Current.origin
# )
#
# credential.id # => Base64URL-encoded credential ID
# credential.public_key # => OpenSSL::PKey::EC
# credential.sign_count # => 0
#
# == Authentication
#
# credential = ActionPack::WebAuthn::PublicKeyCredential.new(
# id: stored_credential_id,
# public_key: stored_public_key,
# sign_count: stored_sign_count
# )
#
# credential.authenticate(params[:passkey])
#
# == Ceremony Options
#
# Use +.creation_options+ and +.request_options+ to generate the JSON options
# passed to the browser's +navigator.credentials.create()+ and
# +navigator.credentials.get()+ calls.
#
# == Attributes
#
# [+id+]
# The Base64URL-encoded credential identifier.
#
# [+public_key+]
# The OpenSSL public key for signature verification.
#
# [+sign_count+]
# The signature counter, used for replay detection.
#
# [+aaguid+]
# The authenticator attestation GUID (set during registration).
#
# [+backed_up+]
# Whether the credential is backed up to cloud storage (synced passkey).
#
# [+transports+]
# Transport hints (e.g., "internal", "usb", "ble", "nfc").
#
class ActionPack::WebAuthn::PublicKeyCredential
attr_reader :id, :public_key, :sign_count, :aaguid, :backed_up, :transports
class << self
# Returns a RequestOptions object for the authentication ceremony.
# Credentials responding to +to_public_key_credential+ are automatically
# transformed.
def request_options(**attributes)
attributes[:credentials] = transform_credentials(attributes[:credentials]) if attributes[:credentials]
ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(**attributes)
end
# Returns a CreationOptions object for the registration ceremony.
# Credentials in +exclude_credentials+ responding to
# +to_public_key_credential+ are automatically transformed.
def creation_options(**attributes)
attributes[:exclude_credentials] = transform_credentials(attributes[:exclude_credentials]) if attributes[:exclude_credentials]
ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(**attributes)
end
# Verifies an attestation response from the browser and returns a new
# PublicKeyCredential with the registered credential data.
#
# Raises +InvalidResponseError+ if the attestation is invalid.
def register(params, challenge: ActionPack::WebAuthn::Current.challenge, origin: ActionPack::WebAuthn::Current.origin)
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: params[:client_data_json],
attestation_object: params[:attestation_object],
challenge: challenge,
origin: origin
)
response.validate!
new(
id: response.attestation.credential_id,
public_key: response.attestation.public_key,
sign_count: response.attestation.sign_count,
aaguid: response.attestation.aaguid,
backed_up: response.attestation.backed_up?,
transports: Array(params[:transports])
)
end
private
def transform_credentials(credentials)
Array(credentials).map do |credential|
if credential.respond_to?(:to_public_key_credential)
credential.to_public_key_credential
else
credential
end
end
end
end
def initialize(id:, public_key:, sign_count:, aaguid: nil, backed_up: nil, transports: [])
@id = id
@public_key = public_key
@public_key = OpenSSL::PKey.read(public_key) unless public_key.is_a?(OpenSSL::PKey::PKey)
@sign_count = sign_count
@aaguid = aaguid
@backed_up = backed_up
@transports = transports
end
# Verifies an assertion response against this credential's public key.
# Updates +sign_count+ and +backed_up+ on success.
#
# Raises +InvalidResponseError+ if the assertion is invalid.
def authenticate(params, challenge: ActionPack::WebAuthn::Current.challenge, origin: ActionPack::WebAuthn::Current.origin)
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: params[:client_data_json],
authenticator_data: params[:authenticator_data],
signature: params[:signature],
credential: self,
challenge: challenge,
origin: origin
)
response.validate!
@sign_count = response.authenticator_data.sign_count
@backed_up = response.authenticator_data.backed_up?
end
# Returns a Hash of the credential data suitable for persisting.
def to_h
{
credential_id: id,
public_key: public_key.to_der,
sign_count: sign_count,
aaguid: aaguid,
backed_up: backed_up,
transports: transports
}
end
end
@@ -0,0 +1,108 @@
# = Action Pack WebAuthn Public Key Credential Creation Options
#
# Generates options for the WebAuthn registration ceremony (creating a new
# credential). These options are passed to +navigator.credentials.create()+ in
# the browser to prompt the user to register an authenticator.
#
# == Usage
#
# options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
# id: current_user.id,
# name: current_user.email,
# display_name: current_user.name
# )
#
# # In your controller, return as JSON for the JavaScript WebAuthn API
# render json: { publicKey: options.as_json }
#
# == Attributes
#
# [+id+]
# A unique identifier for the user account. Will be Base64URL-encoded in the
# output. This should be an opaque identifier (like a primary key), not
# personally identifiable information.
#
# [+name+]
# A human-readable identifier for the user account, typically an email
# address or username. Displayed by the authenticator.
#
# [+display_name+]
# A human-friendly name for the user, typically their full name. Displayed
# by the authenticator during registration.
#
# [+relying_party+]
# The relying party (your application) configuration. Defaults to
# +ActionPack::WebAuthn.relying_party+.
#
# == Supported Algorithms
#
# By default, supports ES256 (ECDSA with P-256 and SHA-256), EdDSA
# (Ed25519), and RS256 (RSASSA-PKCS1-v1_5 with SHA-256), which cover
# the vast majority of authenticators.
class ActionPack::WebAuthn::PublicKeyCredential::CreationOptions < ActionPack::WebAuthn::PublicKeyCredential::Options
ES256 = { type: "public-key", alg: -7 }.freeze
EDDSA = { type: "public-key", alg: -8 }.freeze
RS256 = { type: "public-key", alg: -257 }.freeze
RESIDENT_KEY_OPTIONS = %i[ preferred required discouraged ].freeze
ATTESTATION_PREFERENCES = %i[ none indirect direct enterprise ].freeze
attribute :id
attribute :name
attribute :display_name
attribute :resident_key, default: :required
attribute :exclude_credentials, default: -> { [] }
attribute :attestation, default: :none
attribute :challenge_expiration, default: -> { Rails.configuration.action_pack.web_authn.creation_challenge_expiration }
validates :id, :name, :display_name, presence: true
validates :resident_key, inclusion: { in: RESIDENT_KEY_OPTIONS }
validates :attestation, inclusion: { in: ATTESTATION_PREFERENCES }
def initialize(attributes = {})
super
self.resident_key = resident_key.to_sym
self.attestation = attestation.to_sym
validate!
end
# Returns a Hash suitable for JSON serialization and passing to the
# WebAuthn JavaScript API.
def as_json(*)
json = {
challenge: challenge,
rp: relying_party.as_json,
user: {
id: Base64.urlsafe_encode64(id.to_s, padding: false),
name: name,
displayName: display_name
},
pubKeyCredParams: [
ES256,
EDDSA,
RS256
],
authenticatorSelection: {
residentKey: resident_key.to_s,
requireResidentKey: resident_key == :required,
userVerification: user_verification.to_s
}
}
if exclude_credentials.any?
json[:excludeCredentials] = exclude_credentials.map { |credential| exclude_credential_json(credential) }
end
if attestation != :none
json[:attestation] = attestation.to_s
end
json
end
private
def exclude_credential_json(credential)
hash = { type: "public-key", id: credential.id }
hash[:transports] = credential.transports if credential.transports.any?
hash
end
end
@@ -0,0 +1,78 @@
# = Action Pack WebAuthn Public Key Credential Options
#
# Abstract base class for WebAuthn ceremony options. Provides shared
# attributes and challenge generation for both CreationOptions (registration)
# and RequestOptions (authentication).
#
# This class should not be instantiated directly. Use CreationOptions or
# RequestOptions instead.
#
# == Challenge Generation
#
# Each options object generates a signed, expiring challenge via
# +ActionPack::WebAuthn.challenge_verifier+. The challenge is Base64URL-encoded
# and includes an embedded timestamp so the server can reject stale challenges.
#
# == Attributes
#
# [+user_verification+]
# Controls whether user verification (biometrics/PIN) is required. One of
# +:required+, +:preferred+, or +:discouraged+. Defaults to +:preferred+.
#
# [+relying_party+]
# The RelyingParty configuration. Defaults to +ActionPack::WebAuthn.relying_party+.
#
# [+challenge_expiration+]
# How long the challenge remains valid. Defaults vary by ceremony type
# (configured in the Railtie).
#
class ActionPack::WebAuthn::PublicKeyCredential::Options
include ActiveModel::API
include ActiveModel::Attributes
CHALLENGE_LENGTH = 32
USER_VERIFICATION_OPTIONS = %i[ required preferred discouraged ].freeze
attribute :user_verification, default: :preferred
attribute :relying_party, default: -> { ActionPack::WebAuthn.relying_party }
attribute :challenge_expiration
validates :user_verification, inclusion: { in: USER_VERIFICATION_OPTIONS }
def initialize(attributes = {})
super
self.user_verification = user_verification.to_sym
end
# Validates the options, raising +InvalidOptionsError+ if any are invalid.
def validate!
super
rescue ActiveModel::ValidationError
raise ActionPack::WebAuthn::InvalidOptionsError, errors.full_messages.to_sentence
end
# Returns a human-readable representation of the options.
def inspect
attributes_string = attributes.map { |name, value| "#{name}: #{value.inspect}" }.join(", ")
"#<#{self.class.name} #{attributes_string}>"
end
# Returns a Base64URL-encoded signed challenge containing a random nonce and
# an embedded timestamp. The challenge is generated once and memoized for the
# lifetime of this object.
#
# The timestamp allows the server to reject stale challenges. The expiration
# window is configurable per-ceremony via
# +config.action_pack.web_authn.creation_challenge_expiration+ and
# +config.action_pack.web_authn.request_challenge_expiration+, or per-instance
# via the +challenge_expiration+ attribute.
def challenge
@challenge ||= Base64.urlsafe_encode64(
ActionPack::WebAuthn.challenge_verifier.generate(
Base64.strict_encode64(SecureRandom.random_bytes(CHALLENGE_LENGTH)),
expires_in: challenge_expiration
),
padding: false
)
end
end
@@ -0,0 +1,52 @@
# = Action Pack WebAuthn Public Key Credential Request Options
#
# Generates options for the WebAuthn authentication ceremony (using an existing
# credential). These options are passed to +navigator.credentials.get()+ in
# the browser to prompt the user to authenticate with a registered authenticator.
#
# == Usage
#
# options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
# credentials: current_user.webauthn_credentials
# )
#
# # In your controller, return as JSON for the JavaScript WebAuthn API
# render json: { publicKey: options.as_json }
#
# == Attributes
#
# [+credentials+]
# A collection of credential records for the user. Each credential must
# respond to +id+ returning the Base64URL-encoded credential ID, and
# +transports+ returning an array of transport strings.
#
# [+relying_party+]
# The relying party (your application) configuration. Defaults to
# +ActionPack::WebAuthn.relying_party+.
class ActionPack::WebAuthn::PublicKeyCredential::RequestOptions < ActionPack::WebAuthn::PublicKeyCredential::Options
attribute :credentials, default: -> { [] }
attribute :challenge_expiration, default: -> { Rails.configuration.action_pack.web_authn.request_challenge_expiration }
def initialize(attributes = {})
super
validate!
end
# Returns a Hash suitable for JSON serialization and passing to the
# WebAuthn JavaScript API.
def as_json(*)
{
challenge: challenge,
rpId: relying_party.id,
allowCredentials: credentials.map { |credential| allow_credential_json(credential) },
userVerification: user_verification.to_s
}
end
private
def allow_credential_json(credential)
hash = { type: "public-key", id: credential.id }
hash[:transports] = credential.transports if credential.transports.any?
hash
end
end
@@ -0,0 +1,50 @@
# = Action Pack WebAuthn Relying Party
#
# Represents the relying party (your application) in WebAuthn ceremonies. The
# relying party identity is sent to authenticators during registration and
# authentication to scope credentials to your application.
#
# == Usage
#
# # Using defaults (host from Current, name from Rails application)
# relying_party = ActionPack::WebAuthn::RelyingParty.new
#
# # With explicit values
# relying_party = ActionPack::WebAuthn::RelyingParty.new(
# id: "example.com",
# name: "Example Application"
# )
#
# == Attributes
#
# [+id+]
# The relying party identifier, typically the application's domain name
# (e.g., "example.com"). This must match the origin's effective domain
# or be a registrable domain suffix of it. Credentials are scoped to this
# identifier. Defaults to +ActionPack::WebAuthn::Current.host+.
#
# [+name+]
# A human-readable name for your application, displayed by authenticators
# during ceremonies. Defaults to +Rails.application.name+.
class ActionPack::WebAuthn::RelyingParty
attr_reader :id, :name
# Creates a new relying party configuration.
#
# ==== Options
#
# [+:id+]
# Optional. The relying party identifier (domain).
#
# [+:name+]
# Optional. The application display name.
def initialize(id: ActionPack::WebAuthn::Current.host, name: Rails.application.name)
@id = id
@name = name
end
# Returns a Hash suitable for JSON serialization.
def as_json(*)
{ id: id, name: name }
end
end
@@ -0,0 +1,15 @@
module ActionPackPasskeyInferNameFromAaguid
extend ActiveSupport::Concern
class_methods do
def register(...)
super(...).tap do |credential|
credential.update!(name: credential.authenticator.name) if credential.authenticator && credential.name.blank?
end
end
end
def authenticator
Passkey::Authenticator.find_by_aaguid(aaguid)
end
end
@@ -0,0 +1,33 @@
require "test_helper"
class My::PasskeyChallengesControllerTest < ActionDispatch::IntegrationTest
test "returns a fresh challenge" do
untenanted do
post my_passkey_challenge_url
assert_response :success
assert_not_nil response.parsed_body["challenge"]
end
end
test "stores challenge in cookie" do
untenanted do
post my_passkey_challenge_url
jar = ActionDispatch::Cookies::CookieJar.build(request, cookies.to_hash)
assert_equal response.parsed_body["challenge"], jar.encrypted[ActionPack::Passkey::ChallengesController::COOKIE_NAME]
end
end
test "returns a different challenge each time" do
untenanted do
post my_passkey_challenge_url
first_challenge = response.parsed_body["challenge"]
post my_passkey_challenge_url
second_challenge = response.parsed_body["challenge"]
assert_not_equal first_challenge, second_challenge
end
end
end
@@ -0,0 +1,26 @@
require "test_helper"
class My::PasskeysControllerTest < ActionDispatch::IntegrationTest
include WebauthnTestHelper
setup do
sign_in_as :kevin
end
test "index" do
get my_passkeys_path
assert_response :success
end
test "register a passkey" do
challenge = request_webauthn_challenge
assert_difference -> { identities(:kevin).passkeys.count }, 1 do
post my_passkeys_path, params: build_attestation_params(challenge: challenge)
end
passkey = identities(:kevin).passkeys.order(created_at: :desc).first
assert_redirected_to edit_my_passkey_path(passkey, created: true)
assert_equal [ "internal" ], passkey.transports
end
end
@@ -0,0 +1,101 @@
require "test_helper"
class Sessions::PasskeysControllerTest < ActionDispatch::IntegrationTest
include WebauthnTestHelper
setup do
@identity = identities(:kevin)
@credential = @identity.passkeys.create!(
name: "Test Passkey",
credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false),
public_key: webauthn_private_key.public_to_der,
sign_count: 0,
transports: [ "internal" ]
)
end
test "successful authentication" do
untenanted do
challenge = request_webauthn_challenge
post session_passkey_url, params: build_assertion_params(challenge: challenge, credential: @credential)
assert_response :redirect
assert cookies[:session_token].present?
assert_redirected_to landing_path
end
end
test "updates sign count" do
untenanted do
challenge = request_webauthn_challenge
post session_passkey_url, params: build_assertion_params(challenge: challenge, credential: @credential, sign_count: 1)
assert_equal 1, @credential.reload.sign_count
end
end
test "rejects invalid signature" do
untenanted do
challenge = request_webauthn_challenge
params = build_assertion_params(challenge: challenge, credential: @credential)
params[:passkey][:signature] = Base64.urlsafe_encode64("invalid", padding: false)
post session_passkey_url, params: params
assert_redirected_to new_session_path
assert_not cookies[:session_token].present?
assert_equal "That passkey didn't work. Try again.", flash[:alert]
end
end
test "rejects unknown credential" do
untenanted do
request_webauthn_challenge
post session_passkey_url, params: {
passkey: {
id: "nonexistent",
client_data_json: Base64.urlsafe_encode64("{}", padding: false),
authenticator_data: Base64.urlsafe_encode64("x", padding: false),
signature: Base64.urlsafe_encode64("x", padding: false)
}
}
assert_redirected_to new_session_path
assert_not cookies[:session_token].present?
end
end
test "successful authentication via JSON" do
untenanted do
challenge = request_webauthn_challenge
post session_passkey_url(format: :json), params: build_assertion_params(challenge: challenge, credential: @credential)
assert_response :success
assert @response.parsed_body["session_token"].present?
end
end
test "failed authentication via JSON" do
untenanted do
request_webauthn_challenge
post session_passkey_url(format: :json), params: {
passkey: {
id: "nonexistent",
client_data_json: Base64.urlsafe_encode64("{}", padding: false),
authenticator_data: Base64.urlsafe_encode64("x", padding: false),
signature: Base64.urlsafe_encode64("x", padding: false)
}
}
assert_response :unauthorized
assert_equal "That passkey didn't work. Try again.", @response.parsed_body["message"]
end
end
end
+92
View File
@@ -0,0 +1,92 @@
require "test_helper"
class ActionPack::PasskeyTest < ActiveSupport::TestCase
setup do
@identity = identities(:kevin)
@private_key = OpenSSL::PKey::EC.generate("prime256v1")
ActionPack::WebAuthn::Current.host = "www.example.com"
ActionPack::WebAuthn::Current.origin = "http://www.example.com"
@passkey = @identity.passkeys.create!(
credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false),
public_key: @private_key.public_to_der,
sign_count: 0,
transports: [ "internal" ]
)
end
test "authenticate with valid assertion" do
challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge
assertion = build_assertion(challenge: challenge)
result = @passkey.authenticate(assertion, challenge: challenge)
assert_equal @passkey, result
end
test "authenticate returns nil with invalid signature" do
challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge
assertion = build_assertion(challenge: challenge)
assertion[:signature] = Base64.urlsafe_encode64("invalid", padding: false)
assert_nil @passkey.authenticate(assertion, challenge: challenge)
end
test "authenticate updates sign count and backed_up" do
challenge = ActionPack::Passkey.request_options(credentials: [ @passkey ]).challenge
assertion = build_assertion(challenge: challenge, sign_count: 5, backed_up: true)
@passkey.authenticate(assertion, challenge: challenge)
assert_equal 5, @passkey.reload.sign_count
assert @passkey.backed_up?
end
test "to_public_key_credential" do
credential = @passkey.to_public_key_credential
assert_equal @passkey.credential_id, credential.id
assert_equal @passkey.sign_count, credential.sign_count
assert_equal @passkey.transports, credential.transports
end
private
def build_assertion(challenge:, sign_count: 1, backed_up: false)
origin = ActionPack::WebAuthn::Current.origin
client_data_json = {
challenge: challenge,
origin: origin,
type: "webauthn.get"
}.to_json
authenticator_data = build_authenticator_data(sign_count: sign_count, backed_up: backed_up)
signature = sign(authenticator_data, client_data_json)
{
id: @passkey.credential_id,
client_data_json: client_data_json,
authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false),
signature: Base64.urlsafe_encode64(signature, padding: false)
}
end
def build_authenticator_data(sign_count:, backed_up: false)
rp_id_hash = Digest::SHA256.digest(ActionPack::WebAuthn::Current.host)
flags = 0x01 | 0x04 # user present + user verified
flags |= 0x08 | 0x10 if backed_up # backup eligible + backup state
bytes = []
bytes.concat(rp_id_hash.bytes)
bytes << flags
bytes.concat([ sign_count ].pack("N").bytes)
bytes.pack("C*")
end
def sign(authenticator_data, client_data_json)
client_data_hash = Digest::SHA256.digest(client_data_json)
signed_data = authenticator_data + client_data_hash
@private_key.sign("SHA256", signed_data)
end
end
@@ -0,0 +1,179 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::AssertionResponseTest < ActiveSupport::TestCase
include WebauthnTestHelper
setup do
ActionPack::WebAuthn::Current.host = "example.com"
@challenge = webauthn_challenge
@origin = "https://example.com"
@client_data_json = {
challenge: @challenge,
origin: @origin,
type: "webauthn.get"
}.to_json
# Generate a real key pair for signature verification
@private_key = OpenSSL::PKey::EC.generate("prime256v1")
@public_key = @private_key
@credential = Struct.new(:public_key, :sign_count).new(@public_key, 0)
@authenticator_data = build_authenticator_data(user_verified: true)
@signature = sign(@authenticator_data, @client_data_json)
@response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: @client_data_json,
authenticator_data: @authenticator_data,
signature: @signature,
credential: @credential,
challenge: @challenge,
origin: @origin
)
end
test "initializes with credential, authenticator data, and signature" do
assert_equal @credential, @response.credential
assert_instance_of ActionPack::WebAuthn::Authenticator::Data, @response.authenticator_data
assert_equal @signature, @response.signature
end
test "validate! succeeds with valid challenge, origin, type, and signature" do
assert_nothing_raised do
@response.validate!
end
end
test "validate! raises when type is not webauthn.get" do
client_data_json = {
challenge: @challenge,
origin: @origin,
type: "webauthn.create"
}.to_json
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: client_data_json,
authenticator_data: @authenticator_data,
signature: sign(@authenticator_data, client_data_json),
credential: @credential,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "Client data type is not webauthn.get", error.message
end
test "validate! raises when signature is invalid" do
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: @client_data_json,
authenticator_data: @authenticator_data,
signature: Base64.urlsafe_encode64("invalid-signature", padding: false),
credential: @credential,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "Invalid signature", error.message
end
test "validate! raises when challenge does not match" do
@response.challenge = "wrong-challenge"
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
end
assert_equal "Challenge does not match", error.message
end
test "validate! raises when origin does not match" do
@response.origin = "https://evil.com"
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
end
assert_equal "Origin does not match", error.message
end
test "validate! succeeds with user_verification preferred when not verified" do
authenticator_data = build_authenticator_data(user_verified: false)
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: @client_data_json,
authenticator_data: authenticator_data,
signature: sign(authenticator_data, @client_data_json),
credential: @credential,
challenge: @challenge,
origin: @origin,
user_verification: :preferred
)
assert_nothing_raised do
response.validate!
end
end
test "validate! succeeds with user_verification required when verified" do
authenticator_data = build_authenticator_data(user_verified: true)
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: @client_data_json,
authenticator_data: authenticator_data,
signature: sign(authenticator_data, @client_data_json),
credential: @credential,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
assert_nothing_raised do
response.validate!
end
end
test "validate! raises with user_verification required when not verified" do
authenticator_data = build_authenticator_data(user_verified: false)
response = ActionPack::WebAuthn::Authenticator::AssertionResponse.new(
client_data_json: @client_data_json,
authenticator_data: authenticator_data,
signature: sign(authenticator_data, @client_data_json),
credential: @credential,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "User verification is required", error.message
end
private
def build_authenticator_data(user_verified:)
rp_id_hash = Digest::SHA256.digest("example.com")
flags = 0x01 # user present
flags |= 0x04 if user_verified
sign_count = 0
bytes = []
bytes.concat(rp_id_hash.bytes)
bytes << flags
bytes.concat([ sign_count ].pack("N").bytes)
bytes.pack("C*")
end
def sign(authenticator_data, client_data_json)
client_data_hash = Digest::SHA256.digest(client_data_json)
signed_data = authenticator_data + client_data_hash
@private_key.sign("SHA256", signed_data)
end
end
@@ -0,0 +1,185 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::AttestationResponseTest < ActiveSupport::TestCase
# Auth data in all objects contains:
# rp_id_hash: SHA-256("example.com") (32 bytes)
# sign_count: 0
# aaguid: 00010203-0405-0607-0809-0a0b0c0d0e0f (16 bytes)
# credential_id: 32 sequential bytes 0x00..0x1f
# cose_key: EC2/ES256 P-256 {1: 2, 3: -7, -1: 1, -2: <x>, -3: <y>}
# {"fmt": "none", "attStmt": {}, "authData": <flags: 0x45 (UP+UV+AT)>}
ATTESTATION_NONE_VERIFIED = [ "a363666d74646e6f6e656761747453746d74a068617574684461" \
"746158a4a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947" \
"4500000000000102030405060708090a0b0c0d0e0f0020000102030405060708090a0b0c" \
"0d0e0f101112131415161718191a1b1c1d1e1fa50102032620012158202ba472104c686f" \
"39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519bdd929" \
"e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
# {"fmt": "none", "attStmt": {}, "authData": <flags: 0x41 (UP+AT)>}
ATTESTATION_NONE_NOT_VERIFIED = [ "a363666d74646e6f6e656761747453746d74a06861757468" \
"4461746158a4a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce" \
"19474100000000000102030405060708090a0b0c0d0e0f0020000102030405060708090a" \
"0b0c0d0e0f101112131415161718191a1b1c1d1e1fa50102032620012158202ba472104c" \
"686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519bd" \
"d929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
# {"fmt": "packed", "attStmt": {}, "authData": <flags: 0x45 (UP+UV+AT)>}
ATTESTATION_PACKED_VERIFIED = [ "a363666d74667061636b65646761747453746d74a068617574" \
"684461746158a4a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586" \
"ce19474500000000000102030405060708090a0b0c0d0e0f0020000102030405060708090" \
"a0b0c0d0e0f101112131415161718191a1b1c1d1e1fa50102032620012158202ba472104" \
"c686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519b" \
"dd929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
include WebauthnTestHelper
setup do
ActionPack::WebAuthn::Current.host = "example.com"
@challenge = webauthn_challenge
@origin = "https://example.com"
@client_data_json = {
challenge: @challenge,
origin: @origin,
type: "webauthn.create"
}.to_json
@response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: ATTESTATION_NONE_VERIFIED,
challenge: @challenge,
origin: @origin
)
end
test "initializes with attestation object" do
assert_not_nil @response.attestation_object
end
test "validate! succeeds with valid challenge, origin, and type" do
assert_nothing_raised do
@response.validate!
end
end
test "validate! succeeds with user_verification preferred when not verified" do
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: ATTESTATION_NONE_NOT_VERIFIED,
challenge: @challenge,
origin: @origin,
user_verification: :preferred
)
assert_nothing_raised do
response.validate!
end
end
test "validate! succeeds with user_verification required when verified" do
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: ATTESTATION_NONE_VERIFIED,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
assert_nothing_raised do
response.validate!
end
end
test "validate! raises with user_verification required when not verified" do
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: ATTESTATION_NONE_NOT_VERIFIED,
challenge: @challenge,
origin: @origin,
user_verification: :required
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "User verification is required", error.message
end
test "validate! raises when type is not webauthn.create" do
client_data_json = {
challenge: @challenge,
origin: @origin,
type: "webauthn.get"
}.to_json
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: client_data_json,
attestation_object: ATTESTATION_NONE_VERIFIED,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "Client data type is not webauthn.create", error.message
end
test "validate! raises when challenge does not match" do
@response.challenge = "wrong-challenge"
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
end
assert_equal "Challenge does not match", error.message
end
test "validate! raises when origin does not match" do
@response.origin = "https://evil.com"
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
end
assert_equal "Origin does not match", error.message
end
test "validate! raises when attestation format is not registered" do
response = ActionPack::WebAuthn::Authenticator::AttestationResponse.new(
client_data_json: @client_data_json,
attestation_object: ATTESTATION_PACKED_VERIFIED,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "Unsupported attestation format: packed", error.message
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: ATTESTATION_PACKED_VERIFIED,
challenge: @challenge,
origin: @origin
)
response.validate!
assert verified
ensure
ActionPack::WebAuthn.attestation_verifiers.delete("packed")
end
end
@@ -0,0 +1,47 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::AttestationTest < ActiveSupport::TestCase
# Attestation object: {"fmt": "none", "attStmt": {}, "authData": <164 bytes>}
# Auth data contains:
# rp_id_hash: SHA-256("example.com") (32 bytes)
# flags: 0x41 (user present + attested credential)
# sign_count: 42
# aaguid: 00010203-0405-0607-0809-0a0b0c0d0e0f (16 bytes)
# credential_id: 32 sequential bytes 0x00..0x1f
# cose_key: EC2/ES256 P-256 {1: 2, 3: -7, -1: 1, -2: <x>, -3: <y>}
ATTESTATION_CBOR = [ "a363666d74646e6f6e656761747453746d74a068617574684461746158a4a3" \
"79a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947410000002a" \
"000102030405060708090a0b0c0d0e0f0020000102030405060708090a0b0c0d0e0f1011" \
"12131415161718191a1b1c1d1e1fa50102032620012158202ba472104c686f39d4b623cc" \
"9324953e7053b47cae818e8cf774203a4f51af7122582069cb8ac519bdd929e2bdbe79e9" \
"f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
CREDENTIAL_ID_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
SIGN_COUNT = 42
test "decodes attestation object" do
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
assert_equal "none", attestation.format
assert_equal({}, attestation.attestation_statement)
assert_instance_of ActionPack::WebAuthn::Authenticator::Data, attestation.authenticator_data
end
test "delegates credential_id to authenticator_data" do
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
assert_equal CREDENTIAL_ID_BASE64, attestation.credential_id
end
test "delegates sign_count to authenticator_data" do
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
assert_equal SIGN_COUNT, attestation.sign_count
end
test "delegates public_key to authenticator_data" do
attestation = ActionPack::WebAuthn::Authenticator::Attestation.decode(ATTESTATION_CBOR)
assert_instance_of OpenSSL::PKey::EC, attestation.public_key
end
end
@@ -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::InvalidResponseError) do
@verifier.verify!(attestation, client_data_json: "")
end
assert_equal "Attestation statement must be empty for 'none' format", error.message
end
end
@@ -0,0 +1,145 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::DataTest < ActiveSupport::TestCase
# Common values:
# rp_id_hash: SHA-256("example.com") (32 bytes)
# sign_count: 42
# aaguid: 00010203-0405-0607-0809-0a0b0c0d0e0f (16 bytes)
# credential_id: 32 sequential bytes 0x00..0x1f
# cose_key: EC2/ES256 P-256 {1: 2, 3: -7, -1: 1, -2: <x>, -3: <y>}
RP_ID_HASH = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947" ].pack("H*")
SIGN_COUNT = 42
CREDENTIAL_ID_BASE64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
COSE_KEY_CBOR = [ "a50102032620012158202ba472104c686f39d4b623cc9324953e7053b47cae81" \
"8e8cf774203a4f51af7122582069cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324" \
"647a1ccd68b8de3ca0" ].pack("H*")
# rp_id_hash(32) + flags 0x01 (UP) + sign_count 42
AUTH_DATA_NO_CREDENTIAL = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2" \
"125586ce1947010000002a" ].pack("H*")
# rp_id_hash(32) + flags 0x41 (UP+AT) + sign_count 42 + aaguid(16) +
# credential_id_len 32 (2 bytes) + credential_id(32) + cose_key CBOR
AUTH_DATA_WITH_CREDENTIAL = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13" \
"d2125586ce1947410000002a000102030405060708090a0b0c0d0e0f0020000102030405" \
"060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fa5010203262001215820" \
"2ba472104c686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af7122582069" \
"cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
# Error test data: flags 0x41 (AT set) but no attested credential data after header
# rp_id_hash(32) + flags 0x41 + sign_count 42
AUTH_DATA_AT_FLAG_NO_CREDENTIAL = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30" \
"ab13d2125586ce1947410000002a" ].pack("H*")
# rp_id_hash(32) + flags 0x41 + sign_count 0 + aaguid(16), missing credential_id_len
AUTH_DATA_TRUNCATED_BEFORE_CRED_LEN = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f" \
"2d30ab13d2125586ce19474100000000000102030405060708090a0b0c0d0e0f" ].pack("H*")
# rp_id_hash(32) + flags 0x41 + sign_count 0 + aaguid(16) + credential_id_len 9999
AUTH_DATA_HUGE_CRED_LEN = [ "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2" \
"125586ce19474100000000000102030405060708090a0b0c0d0e0f270f" ].pack("H*")
test "decodes authenticator data without attested credential" do
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_NO_CREDENTIAL)
assert_equal RP_ID_HASH, data.relying_party_id_hash
assert_equal 0x01, data.flags
assert_equal SIGN_COUNT, data.sign_count
assert_nil data.credential_id
assert_nil data.public_key_bytes
end
test "decodes authenticator data with attested credential" do
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_WITH_CREDENTIAL)
assert_equal RP_ID_HASH, data.relying_party_id_hash
assert_equal 0x41, data.flags
assert_equal SIGN_COUNT, data.sign_count
assert_equal CREDENTIAL_ID_BASE64, data.credential_id
assert_equal COSE_KEY_CBOR, data.public_key_bytes
end
test "user_present? returns true when flag is set" do
data = build_data_with_flags(0x01)
assert data.user_present?
end
test "user_present? returns false when flag is not set" do
data = build_data_with_flags(0x00)
assert_not data.user_present?
end
test "user_verified? returns true when flag is set" do
data = build_data_with_flags(0x04)
assert data.user_verified?
end
test "user_verified? returns false when flag is not set" do
data = build_data_with_flags(0x00)
assert_not data.user_verified?
end
test "backup_eligible? returns true when flag is set" do
data = build_data_with_flags(0x08)
assert data.backup_eligible?
end
test "backup_eligible? returns false when flag is not set" do
data = build_data_with_flags(0x00)
assert_not data.backup_eligible?
end
test "backed_up? returns true when flag is set" do
data = build_data_with_flags(0x10)
assert data.backed_up?
end
test "backed_up? returns false when flag is not set" do
data = build_data_with_flags(0x00)
assert_not data.backed_up?
end
test "public_key returns OpenSSL key when public_key_bytes present" do
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_WITH_CREDENTIAL)
assert_instance_of OpenSSL::PKey::EC, data.public_key
end
test "public_key returns nil when public_key_bytes not present" do
data = ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_NO_CREDENTIAL)
assert_nil data.public_key
end
test "raises when attested credential flag set but data truncated before AAGUID" do
assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_AT_FLAG_NO_CREDENTIAL)
end
end
test "raises when attested credential flag set but data truncated before credential ID" do
assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_TRUNCATED_BEFORE_CRED_LEN)
end
end
test "raises when credential ID length exceeds remaining bytes" do
assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
ActionPack::WebAuthn::Authenticator::Data.decode(AUTH_DATA_HUGE_CRED_LEN)
end
end
private
def build_data_with_flags(flags)
ActionPack::WebAuthn::Authenticator::Data.new(
bytes: [],
relying_party_id_hash: RP_ID_HASH,
flags: flags,
sign_count: 0,
credential_id: nil,
public_key_bytes: nil
)
end
end
@@ -0,0 +1,157 @@
require "test_helper"
class ActionPack::WebAuthn::Authenticator::ResponseTest < ActiveSupport::TestCase
include WebauthnTestHelper
setup do
ActionPack::WebAuthn::Current.host = "example.com"
@challenge = webauthn_challenge
@origin = "https://example.com"
@client_data_json = {
challenge: @challenge,
origin: @origin,
type: "webauthn.create"
}.to_json
@authenticator_data = build_authenticator_data
@response = TestableResponse.new(
client_data_json: @client_data_json,
authenticator_data: @authenticator_data,
challenge: @challenge,
origin: @origin
)
end
class TestableResponse < ActionPack::WebAuthn::Authenticator::Response
attr_reader :authenticator_data
def initialize(authenticator_data:, **attrs)
super(**attrs)
@authenticator_data = authenticator_data
end
end
test "parses client data JSON" do
assert_equal @challenge, @response.client_data["challenge"]
assert_equal @origin, @response.client_data["origin"]
end
test "valid? returns true when challenge and origin match" do
assert @response.valid?
end
test "valid? returns false when challenge does not match" do
@response.challenge = "wrong-challenge"
assert_not @response.valid?
end
test "valid? returns false when origin does not match" do
@response.origin = "https://evil.com"
assert_not @response.valid?
end
test "validate! raises when challenge does not match" do
@response.challenge = "wrong-challenge"
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
end
assert_equal "Challenge does not match", error.message
end
test "validate! raises when origin does not match" do
@response.origin = "https://evil.com"
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
@response.validate!
end
assert_equal "Origin does not match", error.message
end
test "validate! raises when crossOrigin is true" do
client_data_json = {
challenge: @challenge,
origin: @origin,
type: "webauthn.create",
crossOrigin: true
}.to_json
response = TestableResponse.new(
client_data_json: client_data_json,
authenticator_data: @authenticator_data,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "Cross-origin requests are not supported", error.message
end
test "validate! raises when relying party ID does not match" do
rp_id_hash = Digest::SHA256.digest("evil.com")
flags = 0x05
sign_count = 0
bytes = []
bytes.concat(rp_id_hash.bytes)
bytes << flags
bytes.concat([ sign_count ].pack("N").bytes)
wrong_rp_data = ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack("C*"))
response = TestableResponse.new(
client_data_json: @client_data_json,
authenticator_data: wrong_rp_data,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "Relying party ID does not match", error.message
end
test "validate! raises when tokenBinding status is present" do
client_data_json = {
challenge: @challenge,
origin: @origin,
type: "webauthn.create",
tokenBinding: { status: "present", id: "some-id" }
}.to_json
response = TestableResponse.new(
client_data_json: client_data_json,
authenticator_data: @authenticator_data,
challenge: @challenge,
origin: @origin
)
error = assert_raises(ActionPack::WebAuthn::InvalidResponseError) do
response.validate!
end
assert_equal "Token binding is not supported", error.message
end
private
def build_authenticator_data
rp_id_hash = Digest::SHA256.digest("example.com")
flags = 0x05 # user present + user verified
sign_count = 0
bytes = []
bytes.concat(rp_id_hash.bytes)
bytes << flags
bytes.concat([ sign_count ].pack("N").bytes)
ActionPack::WebAuthn::Authenticator::Data.decode(bytes.pack("C*"))
end
end
@@ -0,0 +1,301 @@
require "test_helper"
class ActionPack::WebAuthn::CborDecoderTest < ActiveSupport::TestCase
test "decodes unsigned integer 0" do
assert_equal 0, decode("00")
end
test "decodes unsigned integer 1" do
assert_equal 1, decode("01")
end
test "decodes unsigned integer 10" do
assert_equal 10, decode("0a")
end
test "decodes unsigned integer 23" do
assert_equal 23, decode("17")
end
test "decodes unsigned integer 24 (single byte follows)" do
assert_equal 24, decode("1818")
end
test "decodes unsigned integer 25" do
assert_equal 25, decode("1819")
end
test "decodes unsigned integer 100" do
assert_equal 100, decode("1864")
end
test "decodes unsigned integer 1000 (two bytes follow)" do
assert_equal 1000, decode("1903e8")
end
test "decodes unsigned integer 1000000 (four bytes follow)" do
assert_equal 1000000, decode("1a000f4240")
end
test "decodes unsigned integer 1000000000000 (eight bytes follow)" do
assert_equal 1000000000000, decode("1b000000e8d4a51000")
end
test "decodes negative integer -1" do
assert_equal(-1, decode("20"))
end
test "decodes negative integer -10" do
assert_equal(-10, decode("29"))
end
test "decodes negative integer -100" do
assert_equal(-100, decode("3863"))
end
test "decodes negative integer -1000" do
assert_equal(-1000, decode("3903e7"))
end
test "decodes empty byte string" do
assert_equal "", decode("40")
end
test "decodes byte string with 4 bytes" do
assert_equal "\x01\x02\x03\x04".b, decode("4401020304")
end
test "decodes empty text string" do
result = decode("60")
assert_equal "", result
assert_equal Encoding::UTF_8, result.encoding
end
test "decodes text string 'a'" do
result = decode("6161")
assert_equal "a", result
assert_equal Encoding::UTF_8, result.encoding
end
test "decodes text string 'IETF'" do
result = decode("6449455446")
assert_equal "IETF", result
assert_equal Encoding::UTF_8, result.encoding
end
test "decodes text string with unicode" do
result = decode("62c3bc")
assert_equal "ü", result
assert_equal Encoding::UTF_8, result.encoding
end
test "decodes empty array" do
assert_equal [], decode("80")
end
test "decodes array [1, 2, 3]" do
assert_equal [ 1, 2, 3 ], decode("83010203")
end
test "decodes nested array [1, [2, 3], [4, 5]]" do
assert_equal [ 1, [ 2, 3 ], [ 4, 5 ] ], decode("8301820203820405")
end
test "decodes array with 25 elements" do
expected = (1..25).to_a
# 0x9819 = array with 25 elements (0x98 = type 4 + additional 24, 0x19 = 25)
# integers 1-23 encode as single bytes, 24 = 0x1818, 25 = 0x1819
elements = (1..23).map { |n| format("%02x", n) }.join + "18181819"
assert_equal expected, decode("9819" + elements)
end
test "decodes empty map" do
assert_equal({}, decode("a0"))
end
test "decodes map {1: 2, 3: 4}" do
assert_equal({ 1 => 2, 3 => 4 }, decode("a201020304"))
end
test "decodes map with string keys" do
assert_equal({ "a" => 1, "b" => 2 }, decode("a2616101616202"))
end
test "decodes nested map" do
assert_equal({ "a" => { "b" => 1 } }, decode("a16161a1616201"))
end
test "decodes false" do
assert_equal false, decode("f4")
end
test "decodes true" do
assert_equal true, decode("f5")
end
test "decodes null" do
assert_nil decode("f6")
end
test "decodes undefined as nil" do
assert_nil decode("f7")
end
test "decodes tagged value, ignoring tag" do
# 0xc0 = tag 0 (date/time string), followed by text "2013-03-21T20:04:00Z"
assert_equal "2013-03-21T20:04:00Z", decode("c074323031332d30332d32315432303a30343a30305a")
end
test "decodes tagged integer" do
# 0xc1 = tag 1 (epoch time), followed by integer 1363896240
assert_equal 1363896240, decode("c11a514b67b0")
end
test "raises error for reserved additional info values" do
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
decode("1c")
end
end
test "decodes indefinite length array" do
assert_equal [ 1, 2, 3 ], decode("9f010203ff")
end
test "decodes empty indefinite length array" do
assert_equal [], decode("9fff")
end
test "decodes empty indefinite length map" do
assert_equal({}, decode("bfff"))
end
test "decodes indefinite length map" do
assert_equal({ "a" => 1, "b" => 2 }, decode("bf616101616202ff"))
end
test "decodes indefinite length byte string" do
assert_equal "\x01\x02\x03".b, decode("5f4201024103ff")
end
test "decodes indefinite length text string" do
result = decode("7f657374726561646d696e67ff")
assert_equal "streaming", result
assert_equal Encoding::UTF_8, result.encoding
end
test "decodes half-precision float 0.0" do
assert_equal 0.0, decode("f90000")
end
test "decodes half-precision float 1.0" do
assert_equal 1.0, decode("f93c00")
end
test "decodes half-precision float 1.5" do
assert_equal 1.5, decode("f93e00")
end
test "decodes half-precision float -4.0" do
assert_equal(-4.0, decode("f9c400"))
end
test "decodes half-precision positive infinity" do
assert_equal Float::INFINITY, decode("f97c00")
end
test "decodes half-precision NaN" do
assert_predicate decode("f97e00"), :nan?
end
test "decodes single-precision float 100000.0" do
assert_equal 100000.0, decode("fa47c35000")
end
test "decodes single-precision positive infinity" do
assert_equal Float::INFINITY, decode("fa7f800000")
end
test "decodes double-precision float 1.1" do
assert_in_delta 1.1, decode("fb3ff199999999999a"), 0.0001
end
test "decodes double-precision float -4.1" do
assert_in_delta(-4.1, decode("fbc010666666666666"), 0.0001)
end
test "decodes double-precision positive infinity" do
assert_equal Float::INFINITY, decode("fb7ff0000000000000")
end
test "decodes double-precision negative infinity" do
assert_equal(-Float::INFINITY, decode("fbfff0000000000000"))
end
test "raises error for unsupported simple value" do
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
decode("e0")
end
end
test "decode accepts string input" do
bytes = [ 0x01 ].pack("C*")
assert_equal 1, ActionPack::WebAuthn::CborDecoder.decode(bytes)
end
test "decode accepts array input" do
assert_equal 1, ActionPack::WebAuthn::CborDecoder.decode([ 0x01 ])
end
test "raises error for empty input" do
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
ActionPack::WebAuthn::CborDecoder.decode([])
end
end
test "raises error for truncated byte string" do
# 0x44 = byte string of length 4, but only 2 bytes follow
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
decode("440102")
end
end
test "raises error for truncated integer" do
# 0x19 = 2-byte integer follows, but only 1 byte provided
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
decode("19ff")
end
end
test "raises error for truncated array" do
# 0x82 = array of 2 items, but only 1 provided
assert_raises(ActionPack::WebAuthn::InvalidCborError) do
decode("8201")
end
end
test "raises error for deeply nested structure" do
# Build array nested 20 levels deep: [[[[...]]]]
# 0x81 = array of 1 item
deeply_nested = "81" * 20 + "01"
error = assert_raises(ActionPack::WebAuthn::InvalidCborError) do
decode(deeply_nested)
end
assert_equal "Maximum nesting depth exceeded", error.message
end
test "raises error for input exceeding max size" do
error = assert_raises(ActionPack::WebAuthn::InvalidCborError) do
ActionPack::WebAuthn::CborDecoder.decode([ 0x01 ], max_size: 0)
end
assert_equal "Input exceeds maximum size", error.message
end
private
def decode(hex)
bytes = [ hex ].pack("H*").bytes
ActionPack::WebAuthn::CborDecoder.decode(bytes)
end
end
@@ -0,0 +1,255 @@
require "test_helper"
class ActionPack::WebAuthn::CoseKeyTest < ActiveSupport::TestCase
# EC2/ES256 P-256 public key (32-byte x and y coordinates)
EC2_X = [ "2ba472104c686f39d4b623cc9324953e7053b47cae818e8cf774203a4f51af71" ].pack("H*")
EC2_Y = [ "69cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324647a1ccd68b8de3ca0" ].pack("H*")
# CBOR: {1: 2, 3: -7, -1: 1, -2: <x 32 bytes>, -3: <y 32 bytes>}
EC2_CBOR = [ "a50102032620012158202ba472104c686f39d4b623cc9324953e7053b47cae81" \
"8e8cf774203a4f51af7122582069cb8ac519bdd929e2bdbe79e9f9b8d14c2d89a7cbd324" \
"647a1ccd68b8de3ca0" ].pack("H*")
# Ed25519 public key (32 bytes)
ED25519_X = [ "a95ee02872a2c5224b394832767bea746620e50776e845872228716065f16005" ].pack("H*")
# CBOR: {1: 1, 3: -8, -1: 6, -2: <x 32 bytes>}
OKP_CBOR = [ "a4010103272006215820a95ee02872a2c5224b394832767bea746620e50776e8" \
"45872228716065f16005" ].pack("H*")
# RSA 2048-bit public key (256-byte modulus, 3-byte exponent 65537)
RSA_N = [ "d388adb3aa7812402281c57ce870821b17558f0a247a771834892d85399ecd4f" \
"830dd35f65e7afe5030d9ee10f4873567039976486202cce8ac499114194d32fe615026e" \
"7eeee5b2ff564041d68b9b33c35a2ac17210c69c9e85fa74249b06e4ffa6b38ff5ef54e" \
"1860aa59a6fb043e2b65ecf0ce8d0ff90d25683ca2da016618308f3fa7f74efc178ec46" \
"e0224f10cf0eed7d46cc6167210f088cc6b77fc08a7fcd14536aa9c726519806a96ea00" \
"517ce1ed1336ae6962338a6c4cc4754d953ebbffb5d6b1bc76368b552b628adb788b0bc" \
"9f895dff6b1c74d79ce210b5941995beb1f498a1e9123666bdc92bc6b0f2a04fdb40cf1" \
"d253ba1582673ec293113" ].pack("H*")
RSA_E = [ "010001" ].pack("H*")
# CBOR: {1: 3, 3: -257, -1: <n 256 bytes>, -2: <e 3 bytes>}
RSA_CBOR = [ "a401030339010020590100d388adb3aa7812402281c57ce870821b17558f0a24" \
"7a771834892d85399ecd4f830dd35f65e7afe5030d9ee10f4873567039976486202cce8a" \
"c499114194d32fe615026e7eeee5b2ff564041d68b9b33c35a2ac17210c69c9e85fa742" \
"49b06e4ffa6b38ff5ef54e1860aa59a6fb043e2b65ecf0ce8d0ff90d25683ca2da01661" \
"8308f3fa7f74efc178ec46e0224f10cf0eed7d46cc6167210f088cc6b77fc08a7fcd145" \
"36aa9c726519806a96ea00517ce1ed1336ae6962338a6c4cc4754d953ebbffb5d6b1bc7" \
"6368b552b628adb788b0bc9f895dff6b1c74d79ce210b5941995beb1f498a1e9123666b" \
"dc92bc6b0f2a04fdb40cf1d253ba1582673ec2931132143010001" ].pack("H*")
setup do
@ec2_parameters = {
1 => 2, # kty: EC2
3 => -7, # alg: ES256
-1 => 1, # crv: P-256
-2 => EC2_X,
-3 => EC2_Y
}
@rsa_parameters = {
1 => 3, # kty: RSA
3 => -257, # alg: RS256
-1 => RSA_N,
-2 => RSA_E
}
@okp_parameters = {
1 => 1, # kty: OKP
3 => -8, # alg: EdDSA
-1 => 6, # crv: Ed25519
-2 => ED25519_X
}
end
test "initializes with key type, algorithm, and parameters" do
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 2,
algorithm: -7,
parameters: @ec2_parameters
)
assert_equal 2, key.key_type
assert_equal(-7, key.algorithm)
assert_equal @ec2_parameters, key.parameters
end
test "decodes EC2/ES256 key from CBOR" do
key = ActionPack::WebAuthn::CoseKey.decode(EC2_CBOR)
assert_equal 2, key.key_type
assert_equal(-7, key.algorithm)
end
test "decodes OKP/EdDSA key from CBOR" do
key = ActionPack::WebAuthn::CoseKey.decode(OKP_CBOR)
assert_equal 1, key.key_type
assert_equal(-8, key.algorithm)
end
test "decodes RSA/RS256 key from CBOR" do
key = ActionPack::WebAuthn::CoseKey.decode(RSA_CBOR)
assert_equal 3, key.key_type
assert_equal(-257, key.algorithm)
end
test "converts EC2/ES256 key to OpenSSL EC key" do
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 2,
algorithm: -7,
parameters: @ec2_parameters
)
openssl_key = key.to_openssl_key
assert_instance_of OpenSSL::PKey::EC, openssl_key
assert_equal "prime256v1", openssl_key.group.curve_name
end
test "converts OKP/EdDSA key to OpenSSL Ed25519 key" do
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 1,
algorithm: -8,
parameters: @okp_parameters
)
openssl_key = key.to_openssl_key
assert_equal "ED25519", openssl_key.oid
end
test "converts RSA/RS256 key to OpenSSL RSA key" do
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 3,
algorithm: -257,
parameters: @rsa_parameters
)
openssl_key = key.to_openssl_key
assert_instance_of OpenSSL::PKey::RSA, openssl_key
assert_equal 65537, openssl_key.e.to_i
end
test "raises error for unsupported key type/algorithm combination" do
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 99,
algorithm: -7,
parameters: {}
)
error = assert_raises(ActionPack::WebAuthn::UnsupportedKeyTypeError) do
key.to_openssl_key
end
assert_match(/99\/-7/, error.message)
end
test "raises error for unsupported OKP curve" do
parameters = @okp_parameters.merge(-1 => 5) # Ed448 instead of Ed25519
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 1,
algorithm: -8,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::UnsupportedKeyTypeError) do
key.to_openssl_key
end
assert_match(/curve/, error.message.downcase)
end
test "raises error for unsupported EC curve" do
parameters = @ec2_parameters.merge(-1 => 2) # P-384 instead of P-256
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 2,
algorithm: -7,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::UnsupportedKeyTypeError) do
key.to_openssl_key
end
assert_match(/curve/, error.message.downcase)
end
test "raises error for EC2 key with missing coordinates" do
parameters = @ec2_parameters.except(-3) # missing y coordinate
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 2,
algorithm: -7,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
key.to_openssl_key
end
assert_match(/missing ec2 key coordinates/i, error.message)
end
test "raises error for EC2 key with wrong coordinate length" do
parameters = @ec2_parameters.merge(-2 => "\x00" * 16) # 16 bytes instead of 32
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 2,
algorithm: -7,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
key.to_openssl_key
end
assert_match(/invalid ec2 coordinate length/i, error.message)
end
test "raises error for OKP key with missing coordinate" do
parameters = @okp_parameters.except(-2) # missing x coordinate
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 1,
algorithm: -8,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
key.to_openssl_key
end
assert_match(/missing okp key coordinate/i, error.message)
end
test "raises error for RSA key with missing parameters" do
parameters = @rsa_parameters.except(-1) # missing n
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 3,
algorithm: -257,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
key.to_openssl_key
end
assert_match(/missing rsa key parameters/i, error.message)
end
test "raises error for RSA key smaller than 2048 bits" do
small_n = "\x01" + ("\x00" * 127) # 1024-bit modulus
parameters = @rsa_parameters.merge(-1 => small_n)
key = ActionPack::WebAuthn::CoseKey.new(
key_type: 3,
algorithm: -257,
parameters: parameters
)
error = assert_raises(ActionPack::WebAuthn::InvalidKeyError) do
key.to_openssl_key
end
assert_match(/at least 2048 bits/i, error.message)
end
end
@@ -0,0 +1,142 @@
require "test_helper"
class ActionPack::WebAuthn::PublicKeyCredential::CreationOptionsTest < ActiveSupport::TestCase
setup do
@relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
@options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
id: "user-123",
name: "user@example.com",
display_name: "Test User",
relying_party: @relying_party
)
end
test "initializes with required parameters" do
assert_equal "user-123", @options.id
assert_equal "user@example.com", @options.name
assert_equal "Test User", @options.display_name
assert_equal @relying_party, @options.relying_party
end
test "generates base64url encoded challenge" do
assert_match(/\A[A-Za-z0-9_-]+\z/, @options.challenge)
end
test "generates signed challenge containing nonce" do
signed_message = Base64.urlsafe_decode64(@options.challenge)
nonce = ActionPack::WebAuthn.challenge_verifier.verified(signed_message)
assert_not_nil nonce
assert_equal 32, Base64.strict_decode64(nonce).bytesize
end
test "as_json" do
assert_equal @options.challenge, @options.as_json[:challenge]
assert_equal({ id: "example.com", name: "Example App" }, @options.as_json[:rp])
user = @options.as_json[:user]
assert_equal Base64.urlsafe_encode64("user-123", padding: false), user[:id]
assert_equal "user@example.com", user[:name]
assert_equal "Test User", user[:displayName]
assert_equal [
{ type: "public-key", alg: -7 },
{ type: "public-key", alg: -8 },
{ type: "public-key", alg: -257 }
], @options.as_json[:pubKeyCredParams]
assert_equal "required", @options.as_json[:authenticatorSelection][:residentKey]
assert_equal true, @options.as_json[:authenticatorSelection][:requireResidentKey]
assert_equal "preferred", @options.as_json[:authenticatorSelection][:userVerification]
end
test "as_json includes residentKey in authenticatorSelection" do
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
id: "user-123",
name: "user@example.com",
display_name: "Test User",
resident_key: :required,
relying_party: @relying_party
)
assert_equal "required", options.as_json[:authenticatorSelection][:residentKey]
assert_equal true, options.as_json[:authenticatorSelection][:requireResidentKey]
end
test "as_json excludes excludeCredentials when empty" do
assert_nil @options.as_json[:excludeCredentials]
end
test "as_json includes excludeCredentials" do
credentials = [
build_credential(id: "cred-1", transports: [ "usb", "nfc" ]),
build_credential(id: "cred-2", transports: [ "internal" ])
]
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
id: "user-123",
name: "user@example.com",
display_name: "Test User",
exclude_credentials: credentials,
relying_party: @relying_party
)
assert_equal [
{ type: "public-key", id: "cred-1", transports: [ "usb", "nfc" ] },
{ type: "public-key", id: "cred-2", transports: [ "internal" ] }
], options.as_json[:excludeCredentials]
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(ActionPack::WebAuthn::InvalidOptionsError) 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
options = ActionPack::WebAuthn::PublicKeyCredential::CreationOptions.new(
id: "user-123",
name: "user@example.com",
display_name: "Test User",
exclude_credentials: [ build_credential(id: "cred-1") ],
relying_party: @relying_party
)
assert_equal [
{ type: "public-key", id: "cred-1" }
], options.as_json[:excludeCredentials]
end
private
def build_credential(id:, transports: [])
ActionPack::WebAuthn::PublicKeyCredential.new(
id: id,
public_key: OpenSSL::PKey::EC.generate("prime256v1"),
sign_count: 0,
transports: transports
)
end
end
@@ -0,0 +1,82 @@
require "test_helper"
class ActionPack::WebAuthn::PublicKeyCredential::RequestOptionsTest < ActiveSupport::TestCase
setup do
@relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
@credentials = [
build_credential(id: "credential-1"),
build_credential(id: "credential-2")
]
@options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
credentials: @credentials,
relying_party: @relying_party
)
end
test "initializes with required parameters" do
assert_equal @credentials, @options.credentials
assert_equal @relying_party, @options.relying_party
end
test "generates base64url encoded challenge" do
assert_match(/\A[A-Za-z0-9_-]+\z/, @options.challenge)
end
test "generates signed challenge containing nonce" do
signed_message = Base64.urlsafe_decode64(@options.challenge)
nonce = ActionPack::WebAuthn.challenge_verifier.verified(signed_message)
assert_not_nil nonce
assert_equal 32, Base64.strict_decode64(nonce).bytesize
end
test "as_json" do
assert_equal @options.challenge, @options.as_json[:challenge]
assert_equal "example.com", @options.as_json[:rpId]
assert_equal [
{ type: "public-key", id: "credential-1" },
{ type: "public-key", id: "credential-2" }
], @options.as_json[:allowCredentials]
assert_equal "preferred", @options.as_json[:userVerification]
end
test "as_json includes transports when present" do
credentials = [
build_credential(id: "cred-1", transports: [ "usb", "nfc" ]),
build_credential(id: "cred-2", transports: [ "internal" ])
]
options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
credentials: credentials,
relying_party: @relying_party
)
assert_equal [
{ type: "public-key", id: "cred-1", transports: [ "usb", "nfc" ] },
{ type: "public-key", id: "cred-2", transports: [ "internal" ] }
], options.as_json[:allowCredentials]
end
test "as_json omits transports when empty" do
credentials = [ build_credential(id: "cred-1") ]
options = ActionPack::WebAuthn::PublicKeyCredential::RequestOptions.new(
credentials: credentials,
relying_party: @relying_party
)
assert_equal [
{ type: "public-key", id: "cred-1" }
], options.as_json[:allowCredentials]
end
private
def build_credential(id:, transports: [])
ActionPack::WebAuthn::PublicKeyCredential.new(
id: id,
public_key: OpenSSL::PKey::EC.generate("prime256v1"),
sign_count: 0,
transports: transports
)
end
end
@@ -0,0 +1,30 @@
require "test_helper"
class ActionPack::WebAuthn::RelyingPartyTest < ActiveSupport::TestCase
test "initializes with explicit id and name" do
relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
assert_equal "example.com", relying_party.id
assert_equal "Example App", relying_party.name
end
test "initializes with default id from Current.host" do
ActionPack::WebAuthn::Current.set(host: "default.example.com") do
relying_party = ActionPack::WebAuthn::RelyingParty.new(name: "Example App")
assert_equal "default.example.com", relying_party.id
end
end
test "initializes with default name from Rails application" do
relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com")
assert_equal Rails.application.name, relying_party.name
end
test "as_json returns id and name" do
relying_party = ActionPack::WebAuthn::RelyingParty.new(id: "example.com", name: "Example App")
assert_equal({ id: "example.com", name: "Example App" }, relying_party.as_json)
end
end
@@ -0,0 +1,32 @@
require "test_helper"
class ActionPackPasskeyInferNameFromAaguidTest < ActiveSupport::TestCase
setup do
@identity = identities(:kevin)
@private_key = OpenSSL::PKey::EC.generate("prime256v1")
ActionPack::WebAuthn::Current.host = "www.example.com"
ActionPack::WebAuthn::Current.origin = "http://www.example.com"
end
test "authenticator lookup by known aaguid" do
authenticator = Passkey::Authenticator.find_by_aaguid("dd4ec289-e01d-41c9-bb89-70fa845d4bf2")
assert_equal "Apple Passwords", authenticator.name
end
test "authenticator lookup returns nil for unknown aaguid" do
assert_nil Passkey::Authenticator.find_by_aaguid("00000000-0000-0000-0000-000000000000")
end
test "authenticator lookup by aaguid on passkey" do
passkey = @identity.passkeys.create!(
credential_id: Base64.urlsafe_encode64(SecureRandom.random_bytes(32), padding: false),
public_key: @private_key.public_to_der,
sign_count: 0,
aaguid: "dd4ec289-e01d-41c9-bb89-70fa845d4bf2"
)
assert_equal "Apple Passwords", passkey.authenticator.name
end
end
@@ -1,6 +1,6 @@
class MagicLinkMailerPreview < ActionMailer::Preview
def magic_link
identity = Identity.new email_address: "test@example.com"
identity = Identity.all.sample
magic_link = MagicLink.new(identity: identity)
magic_link.valid?
+94
View File
@@ -0,0 +1,94 @@
module WebauthnTestHelper
# Fixed EC P-256 key pair for WebAuthn tests.
WEBAUTHN_PRIVATE_KEY = OpenSSL::PKey::EC.new(
[ "307702010104201dd589de7210b3318620f32150e3012cce021519df1d6e9e01" \
"0471146d395cdca00a06082a8648ce3d030107a14403420004116847fe19e1ad" \
"4471ab9980d7ff9cc1e4c7cb7a3af00e082b64fcd84f5ae70114c2495eef16f" \
"542b5e57dd1b263661624e3cf28f581b57a441edbd756a41d0e" ].pack("H*")
)
# Pre-encoded COSE EC2/ES256 public key (CBOR) for the key above.
# {1: 2, 3: -7, -1: 1, -2: <x 32 bytes>, -3: <y 32 bytes>}
COSE_PUBLIC_KEY = [ "a5010203262001215820116847fe19e1ad4471ab9980d7ff9cc1" \
"e4c7cb7a3af00e082b64fcd84f5ae70122582014c2495eef16f542b5e57dd1b2" \
"63661624e3cf28f581b57a441edbd756a41d0e" ].pack("H*")
# CBOR prefix for {"fmt": "none", "attStmt": {}, "authData": bytes(164)}.
# Auth data is always 164 bytes: rp_id_hash(32) + flags(1) + sign_count(4)
# + aaguid(16) + credential_id_length(2) + credential_id(32) + cose_key(77).
ATTESTATION_OBJECT_CBOR_PREFIX =
[ "a363666d74646e6f6e656761747453746d74a068617574684461746158a4" ].pack("H*")
private
def request_webauthn_challenge
untenanted { post my_passkey_challenge_url }
response.parsed_body["challenge"]
end
def webauthn_challenge
ActionPack::WebAuthn::PublicKeyCredential::Options.new.challenge
end
def webauthn_private_key
WEBAUTHN_PRIVATE_KEY
end
def build_attestation_params(challenge:)
credential_id = SecureRandom.random_bytes(32)
auth_data = build_attestation_auth_data(credential_id: credential_id)
{
passkey: {
client_data_json: webauthn_client_data_json(challenge: challenge, type: "webauthn.create"),
attestation_object: Base64.urlsafe_encode64(ATTESTATION_OBJECT_CBOR_PREFIX + auth_data, padding: false),
transports: [ "internal" ]
}
}
end
def build_assertion_params(challenge:, credential:, sign_count: 1)
client_data_json = webauthn_client_data_json(challenge: challenge, type: "webauthn.get")
authenticator_data = build_assertion_auth_data(sign_count: sign_count)
signature = webauthn_sign(authenticator_data, client_data_json)
{
passkey: {
id: credential.credential_id,
client_data_json: client_data_json,
authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false),
signature: Base64.urlsafe_encode64(signature, padding: false)
}
}
end
def webauthn_client_data_json(challenge:, type:)
{ challenge: challenge, origin: "http://www.example.com", type: type }.to_json
end
# Attestation auth data includes the attested credential (public key + credential ID).
def build_attestation_auth_data(credential_id:)
[
Digest::SHA256.digest("www.example.com"), # rp_id_hash
[ 0x45 ].pack("C"), # flags: UP + UV + AT
[ 0 ].pack("N"), # sign_count
"\x00" * 16, # aaguid
[ credential_id.bytesize ].pack("n"), # credential_id_length
credential_id,
COSE_PUBLIC_KEY
].join.b
end
# Assertion auth data is simpler — no attested credential.
def build_assertion_auth_data(sign_count:)
[
Digest::SHA256.digest("www.example.com"),
[ 0x05 ].pack("C"), # flags: UP + UV
[ sign_count ].pack("N")
].join.b
end
def webauthn_sign(authenticator_data, client_data_json)
signed_data = authenticator_data + Digest::SHA256.digest(client_data_json)
WEBAUTHN_PRIVATE_KEY.sign("SHA256", signed_data)
end
end