- Methods: All Methods Documented Methods Hide Methods
- Source: Display Source Hide Source
- Variables: Show Variables Hide Variables
Class ::webauthn::WebAuthn
::webauthn::WebAuthnrp_id: The WebAuthn Relying Party ID (domain), e.g. 'openacs.org' or 'login.example.com'; Must be a registrable domain / host that matches the site origin rules. after_successful_login_url: Where to redirect after login if no return_url exists in state. login_failure_url: Where to send users on failure if you don’t want to show debug output.create ... \
[ -after_successful_login_url (default "/pvt/") ] \
[ -client_id client_id ] \
[ -client_secret client_secret ] \
[ -debug:boolean (default "false") ] \
[ -login_failure_url (default "/") ] \
[ -pretty_name (default "Passkey") ] \
[ -rp_id:required rp_id:required ] \
[ -storageObj (default "::xo::WebAuthnStore::Cache") ]
Defined in packages/webauthn/tcl/webauthn-procs.tcl
Class Relations
::nx::Class create ::webauthn::WebAuthn \ -superclass ::xo::RESTMethods (to be applied on instances)
auth assertion_verify (scripted, public)
<instance of webauthn::WebAuthn> auth assertion_verify \ [ -st st ] [ -req req ]
Verify a WebAuthn authentication response (assertion) against stored state. This method validates the incoming assertion from navigator.credentials.get(). It checks required fields, maps the presented credential ID to a stored credential (user_id + public key), and verifies the assertion using the pending authentication state (challenge, rpId, origin, etc.). If the credential is unknown, an error is raised. When the state contains a user_id (identifier-first flow), the error message is phrased as "no passkey for this account"; otherwise it is treated as an unknown credential in discovery mode.
- Switches:
- -st (optional)
- Authentication state dict as created by /webauthn/auth/options or auth issue_options (challenge, rpId, origin, return_url, ...).
- -req (optional)
- Parsed client response dict containing the assertion fields, including id, clientDataJSON, authenticatorData, and signature.
- Testcases:
- No testcase defined.
set return_url [dict get $st return_url] set expectedRpId [dict get $st rpId] ns_log notice DEBUG: auth/assertion_verify st '$st' req '$req' if {![dict exists $req id] || ![dict exists $req response clientDataJSON] || ![dict exists $req response authenticatorData] || ![dict exists $req response signature]} { throw {validation fields-missing} "missing required fields" } # Map credential -> user set credential_id [dict get $req id] ;# base64url in WebAuthn JSON if {![::xo::dc 0or1row get_cred { select user_id, public_key, sign_count as old_sign_count from webauthn_credentials where credential_id = :credential_id }]} { if {[dict exists $st user_id]} { throw {validation no-passkey} "No passkey registered for this account (or it was removed)." } throw {validation credential-unknown} "unknown credential" } if {[dict exists $st user_id] && $user_id != [dict get $st user_id]} { ns_log notice "webauthn: mismatch" selected_user [dict get $st user_id] credential_user $user_id credential_id $credential_id throw {validation credential-user-mismatch} "Passkey does not match the selected account" } # ---- clientDataJSON set clientData_json [:assert_clientdata_json -clientData_raw [dict get $req response clientDataJSON] -expected_type "webauthn.get" -expected_challenge [dict get $st challenge] -expected_origin [dict get $st origin]] # ---- authenticatorData basic checks (rpIdHash, flags, signCount) set authData_b64u [dict get $req response authenticatorData] set sig_b64u [dict get $req response signature] set authData [ns_base64urldecode -binary -- $authData_b64u] set sig [ns_base64urldecode -binary -- $sig_b64u] if {[string length $authData] < 37} { throw {validation authenicator-invalid} "authenticatorData too short" } set rpIdHash [string range $authData 0 31] :assert_rpidhash -rpIdHash $rpIdHash -rpId $expectedRpId binary scan [string range $authData 32 32] cu flags if {($flags & 0x01) == 0} { throw {validation user-data-missing} "user not present" } binary scan [string range $authData 33 36] Iu new_sign_count # ---- signature verification # verify signature using stored COSE key in $public_key # clientData_json is the *decoded* JSON string of clientDataJSON # Hash must be over the exact bytes that were base64url-decoded. set clientHash [ns_crypto::md string -digest sha256 -binary -encoding binary -- $clientData_json] # signedData = authenticatorData || clientHash set signedData "${authData}${clientHash}" # public_key is a Tcl dict string (from DB) if {![dict exists $public_key cose_b64u]} { throw {validation key-invalid} "stored public key missing cose_b64u" } set coseKey_bin [ns_base64urldecode -binary -- [dict get $public_key cose_b64u]] set cose [ns_cbor decode -binary -encoding binary $coseKey_bin] # Check alg / key type (ES256 expected) if {![dict exists $cose 3] || [dict get $cose 3] != -7} { throw {validation alg-unsupported} "unsupported COSE alg (expected -7 ES256)" } if {![dict exists $cose 1] || [dict get $cose 1] != 2} { throw {validation keytype-unsupported} "unsupported COSE kty (expected 2 EC2)" } if {![dict exists $cose -1] || [dict get $cose -1] != 1} { throw {validation curve-unsupported} "unsupported COSE crv (expected 1 P-256)" } set x [dict get $cose -2] set y [dict get $cose -3] if {[string length $x] != 32 || [string length $y] != 32} { throw {validation key-invalid} "unexpected EC coordinate length" } if {[string length $sig] == 64} { throw {validation signature-format} "unexpected raw 64-byte signature; expected DER" } set pubpem [ns_crypto::eckey fromcoords -curve prime256v1 -x $x -y $y -binary -format pem] set ok [ns_crypto::md string -digest sha256 -binary -encoding binary -verify $pubpem -signature $sig -- $signedData] ns_log notice "DEBUG SIGNATURE OK? $ok" if {!$ok} { throw {validation signature-invalid} "signature verification failed" } ns_log notice DEBUG: update credential_id $credential_id old_sign_count $old_sign_count new_sign_count $new_sign_count db_dml update_last_used { update webauthn_credentials set last_used_at = now(), sign_count = :new_sign_count where credential_id = :credential_id } return $user_idauth issue_options (scripted, public)
<instance of webauthn::WebAuthn> auth issue_options \ [ -return_url return_url ]
Issue WebAuthn assertion options for starting a passkey login ceremony. Generates a fresh state nonce and challenge, stores the pending authentication ceremony state in the configured store (keyed by state), and returns a dict containing: - state: the nonce to be echoed back to /webauthn/auth/verify - options: PublicKeyCredentialRequestOptions for navigator.credentials.get()
- Switches:
- -return_url (optional, defaults to
"/")- Local URL to redirect to after successful login (default: "/").
- Testcases:
- No testcase defined.
set state [::xo::oauth::nonce] set challenge [:new_challenge 32] # Store ceremony state set key [:state_key auth $state] ${:store} set $key [dict create challenge $challenge rpId ${:rp_id} return_url $return_url origin [:origin] ts [ns_time] ] # Return dict the handler can serialize return [dict create state $state options [dict create challenge $challenge timeout 60000 rpId ${:rp_id} userVerification preferred ] ]login_url (scripted, public)
<instance of webauthn::WebAuthn> login_url \ [ -return_url return_url ]
Compatibility function with other external_registry objects
- Switches:
- -return_url (optional, defaults to
"/")- Testcases:
- No testcase defined.
return [export_vars -base /register {return_url}]logout (scripted, public)
<instance of webauthn::WebAuthn> logout
Compatibility function with other external_registry objects
- Testcases:
- No testcase defined.
# The following command leads to an infinite loop. # #ad_user_logout # TODO: for now, this is a NOOP ns_log warning "[current] logout was called, but is not implemented"name (scripted, public)
<instance of webauthn::WebAuthn> name
compatibility with xo::Authorize
- Testcases:
- No testcase defined.
return [expr {[info exists :pretty_name] ? ${:pretty_name} : [namespace tail [self]]}]new_challenge (scripted, public)
<instance of webauthn::WebAuthn> new_challenge [ nbytes ]
Generate a new cryptographically strong random challenge. The challenge is generated using ns_crypto::randombytes and returned as a base64url-encoded string suitable for use in WebAuthn request/creation options.
- Parameters:
- nbytes (optional, defaults to
"32")- Number of random bytes to generate before encoding (default: 32).
- Testcases:
- No testcase defined.
return [ns_crypto::randombytes -encoding base64url $nbytes]origin (scripted, public)
<instance of webauthn::WebAuthn> origin
Returns the "origin" field provided to the attestation.
- Testcases:
- No testcase defined.
# In general, "ns_conn location" would be the right # thing. However, inside a container, it reports the # internal port. set proto [expr {[ad_conn behind_secure_proxy_p] ? "https" : [ns_conn proto]}] # Case-insensitive headers in NaviServer 5; ns_parsehostport validates. set hp [ns_parsehostport [ns_set get [ns_conn headers] Host]] set origin "${proto}://[dict get $hp host]" if {[dict exists $hp port]} { set port [dict get $hp port] if {($proto eq "http" && $port != 80) || ($proto eq "https" && $port != 443)} { append origin ":$port" } } return $originreg attestation_verify (scripted, public)
<instance of webauthn::WebAuthn> reg attestation_verify \ [ -st st ] [ -req req ]
Verify a WebAuthn registration response (attestation) against stored state. This method validates the incoming credential creation response from navigator.credentials.create() for the current registration ceremony. It checks required fields, verifies the clientDataJSON (type, challenge, origin), decodes and parses the attestationObject (CBOR), and extracts credential data (credential ID and public key) for subsequent storage.
- Switches:
- -st (optional)
- Registration state dict as created by /webauthn/reg/options (challenge, origin, return_url, user_id, ...).
- -req (optional)
- Parsed client response dict containing "response" fields, including clientDataJSON and attestationObject.
- Testcases:
- No testcase defined.
set return_url [dict get $st return_url] set user_id [dict get $st user_id] if {![dict exists $req response clientDataJSON] || ![dict exists $req response attestationObject]} { throw {validation fields-missing} "missing required fields" } # ---- clientDataJSON :assert_clientdata_json -clientData_raw [dict get $req response clientDataJSON] -expected_type "webauthn.create" -expected_challenge [dict get $st challenge] -expected_origin [dict get $st origin] # ---- attestationObject (CBOR) -> authData -> credId + COSE_Key set attObj_b64u [dict get $req response attestationObject] set attObj_bin [ns_base64urldecode -binary -- $attObj_b64u] try { set ao [ns_cbor decode -binary -encoding binary $attObj_bin] } on error {e} { throw {validation attobj-cbor} "bad attestationObject CBOR: $e" } if {![dict exists $ao fmt] || ![dict exists $ao authData]} { throw {validation attobj-invalid} "attestationObject missing fmt/authData" } set fmt [dict get $ao fmt] if {$fmt ne "none"} { ns_log notice "registration attestation fmt=$fmt" } set authData [dict get $ao authData] if {[string length $authData] < 55} { throw {validation authdata-invalid} "authData too short" } set rpIdHash [string range $authData 0 31] :assert_rpidhash -rpIdHash $rpIdHash -rpId ${:rp_id} -context "attestation" binary scan [string range $authData 32 32] cu flags if {($flags & 0x40) == 0} { throw {validation attesteddata-missing} "no attested credential data" } binary scan [string range $authData 33 36] Iu signCount # fixed offsets from WebAuthn authData layout set aaguid [string range $authData 37 37+15] binary scan [string range $authData 53 53+1] S credIdLen if {$credIdLen <= 0 || (55+$credIdLen) > [string length $authData]} { throw {validation credidlen-invalid} "credentialId length out of range" } set credId [string range $authData 55 54+$credIdLen] set coseKey [string range $authData 55+$credIdLen end] try { set cose [ns_cbor decode -binary -encoding binary $coseKey] } on error {e} { throw {validation cose-cbor} "bad COSE_Key CBOR: $e" } # Basic COSE sanity (ES256 / P-256) if {![dict exists $cose 3] || [dict get $cose 3] != -7} { throw {validation alg-unsupported} "unsupported COSE alg (expected -7 ES256)" } if {![dict exists $cose 1] || [dict get $cose 1] != 2} { throw {validation keytype-unsupported} "unsupported COSE kty (expected 2 EC2)" } if {![dict exists $cose -1] || [dict get $cose -1] != 1} { throw {validation curve-unsupported} "unsupported COSE crv (expected 1 P-256)" } # Build DB values set credential_id [ns_base64urlencode -binary -- $credId] set public_key [dict create format cose fmt $fmt aaguid_b64u [ns_base64urlencode -binary -- $aaguid] alg [dict get $cose 3] crv [dict get $cose -1] cose_b64u [ns_base64urlencode -binary -- $coseKey] sign_count $signCount rp_id ${:rp_id}] return [dict create user_id $user_id return_url $return_url credential_id $credential_id public_key $public_key origin [dict get $st origin] sign_count $signCount]return_err (scripted, public)
<instance of webauthn::WebAuthn> return_err [ -status status ] \ error detail
Return a JSON error response on the current connection.
- Switches:
- -status (optional, defaults to
"400")- HTTP status code to use for the response (default: 400).
- Parameters:
- error (required)
- Short, stable error code (machine-readable).
- detail (required)
- Human-readable error message suitable for display/logging.
- Testcases:
- No testcase defined.
ns_return $status application/json [subst {{"error":"$error","detail":"$detail"}}]store (scripted, public)
<instance of webauthn::WebAuthn> store
Return the backing store used for pending WebAuthn state.
- Testcases:
- No testcase defined.
return ${:store}
- Methods: All Methods Documented Methods Hide Methods
- Source: Display Source Hide Source
- Variables: Show Variables Hide Variables
![[i]](/resources/acs-subsite/ZoomIn16.gif)