endpoint-procs.tcl
Does not contain a contract.
- Location:
- /packages/webauthn/tcl/endpoint-procs.tcl
Related Files
[ hide source ] | [ make this the default ]
File Contents
# SPDX-License-Identifier: MPL-2.0 #---------------------------------------------------------------------- # GET /webauthn/reg/options #---------------------------------------------------------------------- ns_register_proc GET /webauthn/reg/options { set auth_obj [webauthn::json_contract { Provide WebAuthn registration options for adding a new passkey. Returns PublicKeyCredentialCreationOptions for navigator.credentials.create() for the currently logged-in user. A short-lived server-side registration state is created and keyed by a nonce ("state") to be used by the subsequent verification step. @param return_url Local URL to redirect to after successful registration (default: /pvt/). @param exclude If true, include excludeCredentials to prevent re-registering existing credentials (default: 0). } { {return_url:localurl "/pvt/"} {exclude:boolean 0} }] auth::require_login set state [::xo::oauth::nonce] set rpId [$auth_obj cget -rp_id] set origin [$auth_obj origin] set user_id [ad_conn user_id] set username [party::email -party_id $user_id] if {$username eq ""} { set username "user-$user_id" } set key "webauthn:reg:$state" set challenge [$auth_obj new_challenge 32] ns_log notice DEBUG reg/options registers rpId $rpId origin $origin [$auth_obj store] set $key [dict create \ challenge $challenge \ rpId $rpId \ origin $origin \ return_url $return_url \ user_id $user_id \ ts [ns_time] \ ] # # Optional sanity checks for “known-safe” values (cheap insurance) # #nsf::is integer $user_id #nsf::is wordchar $rpId #nsf::is wordchar $challenge #nsf::is wordchar $state #if {![regexp {^[A-Za-z0-9_-]+=*$} $challenge]} { # # accept base64url/base64-ish (your current challenge seems safe ASCII) # error "challenge contains unexpected characters" #} # # Escape user-controlled strings # set j_username [webauthn::JQ $username] # # excludeCredentials JSON array items # set exclude_json_items {} if {$exclude} { ::xo::dc foreach get_creds { select credential_id from webauthn_credentials where user_id = :user_id and rp_id = :rpId } { # credential_id is base64url (still escape defensively) #nsf::is wordchar $credential_id lappend exclude_json_items [subst {{"type":"public-key","id":"$credential_id"}}] } } set exclude_json "\[[join $exclude_json_items ,]\]" # # Build JSON response (template) # set json [subst -nocommands [ns_trim -delimiter | {{ | "state":"$state", | "publicKey":{ | "rp":{"id":"$rpId","name":"$rpId"}, | "user":{"id":"$user_id","name":"$j_username","displayName":"$j_username"}, | "challenge":"$challenge", | "pubKeyCredParams":[{"type":"public-key","alg":-7}], | "timeout":60000, | "attestation":"none", | "authenticatorSelection":{"residentKey":"preferred","userVerification":"preferred"}, | "excludeCredentials":$exclude_json | } | } }]] ns_log notice DEBUG reg/options JSON=$json ns_log notice DEBUG stored-dict=[dict create \ challenge $challenge \ rpId $rpId \ origin $origin \ return_url $return_url \ user_id $user_id \ ts [ns_time] \ ] ns_return 200 application/json $json } #---------------------------------------------------------------------- # GET /webauthn/reg/verify #---------------------------------------------------------------------- ns_register_proc POST /webauthn/reg/verify { set auth_obj [webauthn::json_contract { Verify a WebAuthn registration response and persist the new credential. This endpoint completes passkey registration for the currently logged-in user. It consumes the pending registration state referenced by "state" (created by /webauthn/reg/options). If the state is missing or expired, verification fails. @param state Opaque registration state/nonce returned by /webauthn/reg/options. } { state:token,notnull }] auth::require_login set rpId [$auth_obj cget -rp_id] set key "webauthn:reg:$state" ns_log notice "DEBUG KEYS LOOKUP: $key (/webauthn/reg/verify)" if {[catch { set st [[$auth_obj store] get $key] }]} { return [$auth_obj return_err "expired-registration" "no pending registration (expired?)"] } set body [ns_conn content] if {[catch { set req [util::json2dict $body] } err]} { return [$auth_obj return_err "invalid-json" "$err"] } try { set r [$auth_obj reg attestation_verify -st $st -req $req] ns_log notice "DEBUG reg attestation_verify receives st {$st} req {$req}" set credential_id [dict get $r credential_id] set user_id [dict get $r user_id] set public_key [dict get $r public_key] set sign_count [dict get $r sign_count] set return_url [dict get $r return_url] set origin [dict get $r origin] set user_agent [ns_set get [ns_conn headers] user-agent] switch -glob -- $user_agent { *iPhone* { set platform "iPhone" } *iPad* { set platform "iPad" } *Android* { set platform "Android" } *Macintosh* { set platform "Mac" } *Windows* { set platform "Windows" } *Linux* { set platform "Linux" } default { set platform "Browser" } } switch -glob -- $user_agent { *EdgiOS/* - *Edg/* { set browser Edge } *OPR/* - *OPiOS/* - *Opera* { set browser Opera } *FxiOS/* - *Firefox/* { set browser Firefox } *CriOS/* - *Chrome/* - *Chromium* { set browser Chrome } *Safari/* { set browser Safari } default { set browser Browser } } set label "$platform · $browser" ::xo::dc dml delete_old_credentials { delete from webauthn_credentials where user_id = :user_id and rp_id = :rpId and label = :label } ::xo::dc dml insert_credentials { insert into webauthn_credentials (credential_id, user_id, rp_id, origin, public_key, sign_count, user_agent, label) values (:credential_id, :user_id, :rpId, :origin, :public_key, :sign_count, :user_agent, :label) } [$auth_obj store] unset $key ns_return 200 application/json [subst -nocommands {{"ok":true,"return_url":"$return_url"}}] } trap validation {errorMsg dict} { set errorCode [lindex [dict get $dict -errorcode] 1] return [$auth_obj return_err $errorCode $errorMsg] } on error {errorMsg} { ns_log error "webauthn reg verify internal error: $errorMsg" return [$auth_obj return_err -status 500 "internal_error" "Internal Error"] return } } #---------------------------------------------------------------------- # GET /webauthn/reg/delete #---------------------------------------------------------------------- ns_register_proc POST /webauthn/reg/delete { webauthn::json_contract { Delete a registered WebAuthn credential (passkey) of the currently logged-in user. The credential is deleted only if it belongs to the current user. Requests for unknown or foreign credentials are handled silently and result in no change. @param credential_id Opaque credential identifier to delete. @param return_url Local URL to redirect to after completion (default: /pvt/). } { credential_id:token,notnull {return_url:localurl "/pvt/"} } auth::require_login set user_id [ad_conn user_id] # Ensure the credential belongs to this user (and delete) set deleted_p 0 db_transaction { set owner_p [db_string cred_owner_check { select 1 from webauthn_credentials where user_id = :user_id and credential_id = :credential_id } -default 0] if {$owner_p} { db_dml delete_cred { delete from webauthn_credentials where user_id = :user_id and credential_id = :credential_id } set deleted_p 1 } } ns_log notice /webauthn/reg/delete credential_id $credential_id deleted_p $deleted_p if {$deleted_p} { ad_returnredirect -message "Passkey deleted." $return_url } else { # Credential not found / not owned by user # Keep it quiet and just go back. ad_returnredirect $return_url } } #---------------------------------------------------------------------- # GET /webauthn/auth/options #---------------------------------------------------------------------- ns_register_proc GET /webauthn/auth/options { set auth_obj [webauthn::json_contract { Provide WebAuthn assertion options for passkey sign-in. Returns PublicKeyCredentialRequestOptions for navigator.credentials.get(). auth_mode behavior: - auto: identifier → identifier mode, otherwise passkey mode - passkey: discovery / account chooser (no allowCredentials) - identifier: restrict options to credentials of resolved account @param return_url Local URL to redirect to after successful login. @param identifier Email or username used in identifier-based login. @param auth_mode One of auto|passkey|identifier (default: auto). } { {return_url:localurl "/"} {identifier:trim ""} {auth_mode:oneof(auto|passkey|identifier) "auto"} }] ns_log notice "DEBUG /webauthn/auth/options" return_url $return_url identifier $identifier auth_mode $auth_mode set state [::xo::oauth::nonce] set rpId [$auth_obj cget -rp_id] set origin [$auth_obj origin] set challenge [$auth_obj new_challenge 32] # # Optional sanity checks for “known-safe” values (cheap insurance) # nsf::is graph $rpId nsf::is wordchar $challenge nsf::is wordchar $state # Store ceremony state (will include intended identity if known) set key "webauthn:auth:$state" set st [dict create \ challenge $challenge \ rpId $rpId \ origin $origin \ return_url $return_url \ ts [ns_time] \ ] set user_id "" set allow_credentials {} if {$auth_mode eq "identifier"} { if {$identifier eq ""} { return [$auth_obj return_err "missing-identifier" "Please enter your email/username first."] } if {[auth::UseEmailForLoginP]} { set user_id [party::get_by_email -email $identifier] } else { set user_id [acs_user::get_by_username -username $identifier] } if {$user_id eq ""} { return [$auth_obj return_err -status 404 "unknown-user" "No such user."] } dict set st identifier $identifier dict set st user_id $user_id # Collect credentials for this user and rpId ::xo::dc foreach get_creds { select credential_id from webauthn_credentials where user_id = :user_id and rp_id = :rpId } { lappend allow_credentials \ [subst -nocommands {{"type":"public-key","id":"$credential_id"}}] } if {[llength $allow_credentials] == 0} { return [$auth_obj return_err -status 404 "no-passkey" "No passkey registered for this account."] } ns_log notice "DEBUG identifier-first resolved" identifier $identifier user_id $user_id ncreds [llength $allow_credentials] } [$auth_obj store] set $key $st ns_log notice "DEBUG allow_credentials" $allow_credentials set allow_json "" if {[llength $allow_credentials] > 0} { set allow_json ,\"allowCredentials\":\[[join $allow_credentials ,]\] } set json [subst -nocommands [ns_trim -delimiter | {{ | "state":"$state", | "publicKey":{ | "challenge":"$challenge", | "timeout":60000, | "rpId":"$rpId", | "userVerification":"preferred"$allow_json | } }}]] ns_log notice /webauthn/auth/options returns $json ns_return 200 application/json $json } #---------------------------------------------------------------------- # GET /webauthn/auth/verify #---------------------------------------------------------------------- ns_register_proc POST /webauthn/auth/verify { set auth_obj [webauthn::json_contract { Verify a WebAuthn assertion response and complete passkey login. This endpoint completes authentication using the pending state referenced by "state" (created by /webauthn/auth/options). If the state is missing or expired, verification fails. @param state Opaque authentication state/nonce returned by /webauthn/auth/options. } { state:token,notnull }] set key "webauthn:auth:$state" try { set st [[$auth_obj store] get $key] } on error {errorMsg} { return [$auth_obj return_err "no pending authentication (expired?)" ""] } # # Process body of the POST request # set body [ns_getcontent -as_file false] if {$body eq ""} { return [$auth_obj return_err "empty request body" ""] } try { set req [util::json2dict $body] } on error {errorMsg} { ns_log notice "JSON parse error: $errorMsg; body='$body'" return [$auth_obj return_err "invalid json" $errorMsg] } # # heck assertions and validity of the signature # try { ns_log notice "DEBUG reg auth/verify st {$st} req {$req}" $auth_obj auth assertion_verify -st $st -req $req } trap validation {errorMsg dict} { set errorCode [lindex [dict get $dict -errorcode] 1] return [$auth_obj return_err $errorCode $errorMsg] } on error {errorMsg} { ns_log error "webauthn verify internal error: $errorMsg" return [$auth_obj return_err -status 500 "internal_error" "Internal Error"] } on ok {user_id} { } ns_log notice "webauthn: can login with user_id $user_id" ad_user_login -external_registry $auth_obj $user_id set return_url [dict get $st return_url] # consume the ceremony state [$auth_obj store] unset $key ns_return 200 application/json [subst {{"ok":true, "return_url":"$return_url"}}] } #---------------------------------------------------------------------- # GET /webauthn/reg #---------------------------------------------------------------------- #ns_register_proc GET /webauthn/reg { # set html [ad_parse_template \ # [template::themed_template "/packages/webauthn/lib/passkey-register"]] # ns_return 200 text/html [ns_adp_parse $html] #} #---------------------------------------------------------------------- # GET /webauthn/login #---------------------------------------------------------------------- #ns_register_proc GET /webauthn/login { # set html [ad_parse_template \ # [template::themed_template "/packages/webauthn/lib/login-handler"]] # ns_return 200 text/html [ns_adp_parse $html] #} #---------------------------------------------------------------------- # GET /webauthn/diagnostics #---------------------------------------------------------------------- ns_register_proc GET /webauthn/diagnostics { set html [ad_parse_template \ [template::themed_template "/packages/webauthn/lib/diagnostics"]] ns_return 200 text/html [ns_adp_parse $html] } #---------------------------------------------------------------------- # GET /webauthn/auth/verify #---------------------------------------------------------------------- # # POST: receive client-side report and log it # ns_register_proc POST /webauthn/diagnostics { set auth_obj [webauthn::json_contract { Receive a client-side diagnostics payload for troubleshooting WebAuthn. The request body must be a JSON object. The payload is logged (single-line) under WEBAUTHN-DIAG together with basic request metadata (ip/host/ref/ua). This endpoint is intended for short-term debugging aid, not telemetry. The optional diag_id is a client-generated identifier included in the JSON payload and extracted for easier log correlation. } {}] # Read body (expect JSON) set body [ns_conn content] if {$body eq ""} { return [$auth_obj return_err "missing-body" "Empty request body."] } # Prevent log spam (adjust as desired) if {[string length $body] > 8192} { return [$auth_obj return_err -status 413 "too-large" "Diagnostics payload too large."] } # Basic JSON sanity check: # - We do not strictly need to parse, but we ensure it "looks like" JSON object set trimmed [string trim $body] if {![string match {\{*} $trimmed] || ![string match {*\}} $trimmed]} { return [$auth_obj return_err "bad-json" "Expected a JSON object."] } # Make it single-line for easier grepping regsub -all {[\r\n\t]+} $trimmed { } oneline set ip [ns_conn peeraddr] set ua [ns_set get [ns_conn headers] User-Agent] set host [ns_set get [ns_conn headers] host] set ref [ns_set iget [ns_conn headers] referer] ;# OpenACS uses iget, but sets are CI in NS5 anyway set diag_id "" if {[regexp {\"diag_id\"\s*:\s*\"([A-Za-z0-9]+)\"} $oneline _ diag_id]} { # ok } ns_log notice "WEBAUTHN-DIAG POST diag_id=$diag_id ip=$ip host={$host} ref={$ref} ua={$ua} payload={$oneline}" ns_return 204 text/plain "" }