From eca4e1eb29aefb9a26f231ad3e8fd35b0a9b31fa Mon Sep 17 00:00:00 2001 From: elpatron Date: Fri, 29 May 2026 13:34:55 +0200 Subject: [PATCH] feat: enhance WebAuthn PRF handling with follow-up authentication and base64url encoding for user IDs --- client/src/services/auth.ts | 74 ++++++++++++++++++++++++++++++++----- scripts/update-prod.sh | 60 ++++++++++++++++++++++++------ server/src/routes/auth.ts | 11 +++++- 3 files changed, 124 insertions(+), 21 deletions(-) diff --git a/client/src/services/auth.ts b/client/src/services/auth.ts index 82ca18b..e73b5cb 100644 --- a/client/src/services/auth.ts +++ b/client/src/services/auth.ts @@ -89,6 +89,52 @@ function base64urlToBuffer(base64url: string): ArrayBuffer { return bytes.buffer } +function randomChallengeBase64url(): string { + const bytes = new Uint8Array(32) + window.crypto.getRandomValues(bytes) + let binary = '' + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]) + } + return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function extractPrfFirst(clientExtensionResults: any): ArrayBuffer | null { + const first = clientExtensionResults?.prf?.results?.first + if (!first) return null + return typeof first === 'string' ? base64urlToBuffer(first) : first +} + +// Some authenticators (notably on Chrome/Android and other platforms) only +// expose the PRF output during an assertion (`navigator.credentials.get`), +// not during credential creation. When that happens we perform a follow-up +// authentication against the freshly created credential purely to obtain the +// PRF output. The assertion itself is not sent to the server. +async function evaluatePrfViaAuthentication( + credentialId: string, + transports?: string[] +): Promise { + try { + const authOptions: any = { + challenge: randomChallengeBase64url(), + allowCredentials: [ + { + id: credentialId, + type: 'public-key', + ...(transports && transports.length ? { transports } : {}) + } + ], + userVerification: 'preferred', + extensions: { prf: { eval: { first: PRF_SALT.buffer } } } + } + const authResponse = await startAuthentication({ optionsJSON: authOptions }) + return extractPrfFirst(authResponse.clientExtensionResults) + } catch (e) { + console.warn('PRF follow-up authentication during registration failed:', e) + return null + } +} + export interface RegistrationResult { verified: boolean recoveryPhrase: string @@ -109,11 +155,14 @@ export async function registerUser(username: string): Promise/dev/null) - + STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$BACKEND_CONTAINER" 2>/dev/null) + if [ "$STATUS" = "healthy" ]; then IS_READY=true break fi - + sleep 1 COUNTER=$((COUNTER + 1)) # Show simple progress dots @@ -66,17 +89,32 @@ echo "" echo "==================================================" echo "Container Statuses:" -docker compose -f $COMPOSE_FILE ps +docker compose -f "$COMPOSE_FILE" ps echo "==================================================" if [ "$IS_READY" = true ]; then echo "SUCCESS: Production environment updated and healthy!" - echo " -> App Frontend (Nginx): http://localhost" - echo " -> Backend API Health: http://localhost/api/health" + echo " -> App Frontend (Nginx): http://${REMOTE_HOST}" + echo " -> Backend API Health: http://${REMOTE_HOST}/api/health" echo "==================================================" else echo "WARNING: Backend did not transition to healthy in time." echo "Check backend container logs for details:" echo " -> docker compose logs backend" echo "==================================================" + exit 3 fi +REMOTE_SCRIPT + +# Capture and report the remote exit status locally +REMOTE_EXIT=$? +echo "==================================================" +if [ $REMOTE_EXIT -eq 0 ]; then + echo "Remote update completed successfully on ${REMOTE_TARGET}." +elif [ $REMOTE_EXIT -eq 3 ]; then + echo "Remote update finished, but the backend was not healthy in time on ${REMOTE_TARGET}." +else + echo "Remote update FAILED on ${REMOTE_TARGET} (exit code: ${REMOTE_EXIT})." +fi +echo "==================================================" +exit $REMOTE_EXIT diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 49078f8..1bb1e29 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -34,10 +34,19 @@ router.post('/register-options', async (req, res) => { return res.status(400).json({ error: 'User already exists' }) } + // NOTE: @simplewebauthn/server v9 places `userID` verbatim into the + // emitted `user.id` JSON field. The browser client (v13) however decodes + // `user.id` as a base64url string. Passing a raw username therefore either + // corrupts the user handle or, for usernames containing characters outside + // the base64url alphabet (".", " ", "@", umlauts, ...), makes the browser + // throw "Invalid character" before the passkey prompt even appears. + // Encoding the username as base64url keeps the value spec-compliant. + const userID = Buffer.from(username, 'utf8').toString('base64url') + const options = await generateRegistrationOptions({ rpName, rpID, - userID: username, + userID, userName: username, userDisplayName: username, attestationType: 'none',