Bug 39601

Summary: Add passkey support to Koha as an authentication mechanism
Product: Koha Reporter: David Nind <david>
Component: AuthenticationAssignee: Paul Derscheid <paul.derscheid>
Status: Failed QA --- QA Contact: Testopia <testopia>
Severity: enhancement    
Priority: P5 - low CC: dcook, dpavlin, paul.derscheid
Version: MainKeywords: dependency
Hardware: All   
OS: All   
GIT URL: Initiative type: ---
Sponsorship status: --- Comma delimited list of Sponsors:
Crowdfunding goal: 0 Patch complexity: ---
Documentation contact: Documentation submission:
Text to go in the release notes:
Version(s) released in:
Circulation function:
Attachments: Bug 39601: Atomic update
[DO NOT PUSH] Bug 39601: DBIC changes
Bug 39601: Add staff passkeys (WebAuthn) support
[DO NOT PUSH] Bug 39601: DBIC changes
[DO NOT PUSH] Bug 39601: DBIC changes

Description David Nind 2025-04-10 10:16:59 UTC
Consider adding passkey support to Koha as an authentication mechanism.

I have seen this method as an option more frequently now with all the various online services I use.

Some articles:
1. Bitwarden article - What are passkeys? https://bitwarden.com/blog/what-are-passkeys-and-passkey-login/
2. tom's guide - What are passkeys? Everything you need to know about the death of passwords https://www.tomsguide.com/news/what-are-passkeys
Comment 1 David Nind 2025-04-10 10:19:10 UTC
See Koha Community Chat thread https://chat.koha-community.org/koha-community/pl/4irec4ocjtyy8pgezeaz9c9nwo

Includes "It could also be a gateway to finally moving "password" out of the borrowers table and into a "credentials" table...".
Comment 2 Paul Derscheid 2025-04-10 10:22:43 UTC
This is properly the best guidance on how to incorporate it:
https://www.passkeys.com/guide

What we also should do beforehand is to decide on how we'd like to do JSON-RPC in Koha :)
See bugs:
- 30652: RPC style action endpoint for choosing matches
- 34784: Add ability to populate empty item call numbers for a record based on the itemcallnumber system preference
- 38224: Move populate_empty_callnumbers logic into Koha::Biblio for reusability
- 38226: Add RPC endpoint for batch filling empty callnumbers
- 38745: Add a proof of concept JSON-RPC driven endpoint for RPC style calls that don't fit REST ideals

Can't yet declare one of them as a dependency for this one.
Comment 3 David Nind 2025-05-11 17:44:51 UTC
An article from Troy Hunt attempting to explain passkeys and why they are needed: Passkeys for Normal People (5 May 2025), https://www.troyhunt.com/passkeys-for-normal-people/
Comment 4 Paul Derscheid 2025-08-10 16:23:06 UTC
Created attachment 185295 [details] [review]
Bug 39601: Atomic update
Comment 5 Paul Derscheid 2025-08-10 16:23:08 UTC
Created attachment 185296 [details] [review]
[DO NOT PUSH] Bug 39601: DBIC changes
Comment 6 Paul Derscheid 2025-08-10 16:23:10 UTC
Created attachment 185297 [details] [review]
Bug 39601: Add staff passkeys (WebAuthn) support

Introduce staff passkey (WebAuthn) registration and authentication in Koha.
Provides REST endpoints, persistence, UI hooks, and session integration so
staff can register a passkey against a patron record and authenticate on the
staff login screen using platform authenticators.

Implementation:
- New controller Koha::REST::V1::Webauthn with endpoints:
  - POST /api/v1/webauthn/register/challenge
  - POST /api/v1/webauthn/register
  - POST /api/v1/webauthn/authenticate/challenge
  - POST /api/v1/webauthn/authenticate
- Use Authen::WebAuthn validate_registration/validate_assertion
- Generate cryptographically secure challenges; store challenge and patron_id
  in the session
- Handle base64url consistently for WebAuthn fields; convert to/from standard
  base64 at the API boundary
- Derive origin and rp_id from StaffClientBaseURL (or the request URL) to
  enforce correct WebAuthn origins
- Persist credentials (credential_id, public_key as raw bytes) in the new
  webauthn_credentials table; set created_on as DATETIME; update sign_count
  and last_used after successful authentication
- Build allowCredentials from stored credential IDs for authentication
  challenges
- On successful authentication, issue a staff session and set the CGISESSID
  cookie so the user is logged in to the staff interface

API:
- Add api/v1/swagger/paths/webauthn.yaml defining the WebAuthn endpoints,
  request/response schemas (including allowCredentials), and x-koha-
  authorization requiring the catalogue permission

DB:
- Add webauthn_credentials table via installer/data/mysql/atomicupdate/
  bug_39601_add_passkey_support.pl
- Add Koha::Schema::Result::WebauthnCredential and Koha::WebauthnCredential(s)
  object classes

UI:
- Staff login: add JS helper (auth-webauthn.inc) to request a challenge,
  convert base64url to bytes, call navigator.credentials.get, send results
  with credentials: "same-origin", and redirect to mainpage on success
- Patron page: add JS helper (passkey-register.inc) to request a registration
  challenge, include RS256 in pubKeyCredParams for compatibility, convert
  base64url to bytes, and submit attestation for storage

Tests:
- t/db_dependent/api/v1/webauthn.t: verify challenge endpoints accept
  patron_id and userid; return 404 when no credentials; include a mocked
  registration negative path; follow Koha testing conventions
- t/db_dependent/Koha/WebauthnCredentials.t: cover ORM add/search/update/delete

Documentation notes:
- StaffClientBaseURL must match the scheme/host used by the browser (typically
  https). Ensure staff is served over the same origin for WebAuthn.
- New routes under /api/v1/webauthn and a new table webauthn_credentials.

Test plan:
1) Apply patches
2) Run database updates to create webauthn_credentials (updatedatabase).
3) Set StaffClientBaseURL to your staff URL (e.g., http://<name>-intra.localhost
   when using ktd_proxy, haven't tested with unproxied ktd)
   and ensure the staff interface is served over the same origin.
4) As a staff user, open a patron record and click Register Passkey from More.
   Complete the OS-native passkey dialog. Verify a row is stored in webauthn_credentials.
   - This worked well in Zen (Firefox under the hood), less so with Chromium.
       - Unsure whether ungoogled Chromium supports using the system password manager,
         worked with a browser-based password manager, though.
       - Best to test with many browsers!
5) Navigate to the staff login page and choose Sign in with passkey. Verify a
   challenge is returned, the browser prompts, and you are logged into the
   staff interface (redirect to mainpage).
6) Call authenticate_challenge for a patron without credentials and verify a
   404 response.
7) Run:
    - prove t/db_dependent/Koha/WebauthnCredential.t
    - prove t/db_dependent/api/v1/webauthn.t
8) Sign off or review and FQA.

NOTE: I think the controllers still need refactoring, this is more a POC regarding the backend.
Comment 7 Paul Derscheid 2025-08-10 16:30:57 UTC
Forgot something: you need to install libauthen-authn-perl via apt.
Comment 8 David Cook 2025-08-10 23:13:57 UTC
(In reply to Paul Derscheid from comment #7)
> Forgot something: you need to install libauthen-authn-perl via apt.

I think that should be libauthen-webauthn-perl
Comment 9 Paul Derscheid 2025-08-11 06:28:02 UTC
Yes, sorry, wrote that last minute from memory (which failed me 😅)
Comment 10 Katrin Fischer 2026-01-07 07:33:28 UTC
* Apply patches
* sudo apt-get install libauthen-webauthn-perl
* updatedatabase
* yarn build 
* restart_all
* ran dbic, but no changes

The QA checks and especially the unit tests failing. Am I missing something else?

prove t/db_dependent/api/v1/webauthn.t
t/db_dependent/api/v1/webauthn.t .. 1/4 
    #   Failed test '404 Not Found'
    #   at t/db_dependent/api/v1/webauthn.t line 60.
    #          got: '500'
    #     expected: '404'

    #   Failed test '404 Not Found'
    #   at t/db_dependent/api/v1/webauthn.t line 63.
    #          got: '500'
    #     expected: '404'
    # Looks like you failed 2 tests of 4.
t/db_dependent/api/v1/webauthn.t .. 2/4 
#   Failed test 'POST /api/v1/webauthn/authenticate/challenge returns 404 without credentials'
#   at t/db_dependent/api/v1/webauthn.t line 67.
t/db_dependent/api/v1/webauthn.t .. 3/4 # Looks like you failed 1 test of 4.
t/db_dependent/api/v1/webauthn.t .. Dubious, test returned 1 (wstat 256, 0x100)
Failed 1/4 subtests 

Test Summary Report
-------------------
t/db_dependent/api/v1/webauthn.t (Wstat: 256 (exited 1) Tests: 4 Failed: 1)
  Failed test:  2
  Non-zero exit status: 1
Files=1, Tests=4,  5 wallclock secs ( 0.00 usr  0.00 sys +  5.69 cusr  0.14 csys =  5.83 CPU)
Result: FAIL

For the test plan, can you explain this step a bit more?

6) Call authenticate_challenge for a patron without credentials and verify a
   404 response.
Comment 11 Paul Derscheid 2026-01-07 09:32:18 UTC
Thanks for testing Katrin, maybe something has changed in main.
I will take a look this afternoon.
Comment 12 Paul Derscheid 2026-01-07 13:53:42 UTC
Just took a look, applying the DBIC changes failed on my end, so that needs to be updated, but something else is broken as well. Will revisit this when I have an open time slot.
Comment 13 Paul Derscheid 2026-01-07 13:55:02 UTC
Created attachment 190988 [details] [review]
[DO NOT PUSH] Bug 39601: DBIC changes
Comment 14 Paul Derscheid 2026-01-07 14:06:28 UTC
Created attachment 190989 [details] [review]
[DO NOT PUSH] Bug 39601: DBIC changes
Comment 15 Paul Derscheid 2026-01-07 14:16:41 UTC
I think the DBIC commit was incorrect in the previous iteration. Corrected it, tests pass now and the registration and authentication flows work in Chromium.
Please have another look if you want, Katrin.
Comment 16 Katrin Fischer 2026-01-12 18:18:28 UTC
Good news first: the tests are passing now!

1) Failing QA tests:

Result:
[FAIL] Koha/Auth/WebAuthn.pm
   FAIL	  forbidden_patterns
		forbidden pattern: Use https:// instead of http:// for URLs (line 17)
   FAIL	  pod_coverage
		sub generate_challenge has no pod coverage.
		sub new has no pod coverage.
		sub verify_assertion has no pod coverage.
		sub verify_registration has no pod coverage.
[PASS] Koha/REST/V1/Auth.pm
[FAIL] Koha/REST/V1/Webauthn.pm
   FAIL	  forbidden_patterns
		forbidden pattern: Use https:// instead of http:// for URLs (line 16)
		forbidden pattern: Use of DateTime->now should certainly be replaced with dt_from_string (bug 24840) (line 257)
		forbidden pattern: Use of DateTime->now should certainly be replaced with dt_from_string (bug 24840) (line 378)
   FAIL	  pod_coverage
		sub authenticate has no pod coverage.
		sub authenticate_challenge has no pod coverage.
		sub register has no pod coverage.
		sub register_challenge has no pod coverage.
   FAIL	  tidiness
		File is not tidy, please run `perl misc/devel/tidy.pl Koha/REST/V1/Webauthn.pm`
[PASS] Koha/Schema/Result/Borrower.pm
[FAIL] Koha/Schema/Result/WebauthnCredential.pm
   FAIL	  forbidden_patterns
		forbidden pattern: The current_timestamp syntax has not been generated correctly (see bug 25040) (line 108)
   SKIP	  pod_coverage
		No POD exists
[SKIP] Koha/WebauthnCredential.pm
   SKIP	  pod_coverage
		No POD exists
[FAIL] Koha/WebauthnCredentials.pm
   FAIL	  pod_coverage
		sub 'object_class' has no pod coverage.
[PASS] api/v1/swagger/paths/webauthn.yaml
[PASS] api/v1/swagger/swagger.yaml
[FAIL] installer/data/mysql/atomicupdate/bug_39601_add_passkey_support.pl
   FAIL	  file_permissions
		File must have the exec flag
[FAIL] koha-tmpl/intranet-tmpl/prog/en/includes/auth-webauthn.inc
   FAIL	  valid_template
		: filter not found
[FAIL] koha-tmpl/intranet-tmpl/prog/en/includes/members-toolbar.inc
   FAIL	  tidiness
		File is not tidy, please run `perl misc/devel/tidy.pl koha-tmpl/intranet-tmpl/prog/en/includes/members-toolbar.inc`
   FAIL	  valid_template
		: filter not found
[FAIL] koha-tmpl/intranet-tmpl/prog/en/includes/passkey-register.inc
   FAIL	  tidiness
		File is not tidy, please run `perl misc/devel/tidy.pl koha-tmpl/intranet-tmpl/prog/en/includes/passkey-register.inc`
   FAIL	  valid_template
		: filter not found
[PASS] koha-tmpl/intranet-tmpl/prog/en/modules/auth.tt
[FAIL] koha-tmpl/intranet-tmpl/prog/js/passkey-register.js
   FAIL	  tidiness
		File is not tidy, please run `perl misc/devel/tidy.pl koha-tmpl/intranet-tmpl/prog/js/passkey-register.js`
[FAIL] koha-tmpl/intranet-tmpl/prog/js/webauthn-login.js
   FAIL	  tidiness
		File is not tidy, please run `perl misc/devel/tidy.pl koha-tmpl/intranet-tmpl/prog/js/webauthn-login.js`
[PASS] members/moremember.pl
[FAIL] t/db_dependent/Koha/WebauthnCredentials.t
   FAIL	  file_permissions
		File must have the exec flag
   FAIL	  test_no_warnings
		'use Test::NoWarnings' must be present in this test file.
[FAIL] t/db_dependent/api/v1/webauthn.t
   FAIL	  file_permissions
		File must have the exec flag
   FAIL	  forbidden_patterns
		forbidden pattern: Use https:// instead of http:// for URLs (line 73)

2) Code review

a) Spelling

We have: 
More > Register Passkey
Create passkey (button)

Probably should be: Register passkey

b) Translatability

Leading spaces usually get lost, better to add this to the i element if possible:

+        <i class="fa fa-key"></i>
+        <span class="d-none d-sm-inline"> Passkey</span>

c) Using text in JS

+    var userid = usernameText ? usernameText.replace('Username:', '').trim() : null;

I am wondering about this line: Where is "Username:" coming from and could this be a translatable strong, so changing?

3) Testing

I should admit that I haven't used passkeys so far, so this is a first and I am still trying to figure out how this works.

a) Ubuntu, Firefox

When I click on "Register Passkey" I see a note at the top of the browser:
Touch your security key to continue with localhost. 
Don't have a security key to touch here, so got stuck at this step. 
Returning to Koha I gott a non-descriptive error message after hitting the button: Registration error: {msg}

Maybe related to a timeout?

b) Ubuntu, Chromium

When I click on "Register Passkey" I see a poo-up with a QR code displayed.
I tried to scan it with Google Authenticator, but the app keeps dying.
It will also display the non-descriptive error message: Registration error: {msg}

4) Questions

a) Is there a way to continue testing here without any hardware? (Yubikey or similar?)
b) For the test plan, can you explain this step a bit more?
   Call authenticate_challenge for a patron without credentials and verify a 404 response.