From 3618b92c3e53b87dfcf73ce15d3226b2e06cd5a2 Mon Sep 17 00:00:00 2001 From: Berg Pinheiro Date: Wed, 8 Jul 2026 20:42:31 -0300 Subject: [PATCH] feat(passkey): Add Passkey (WebAuthn) session pairing - New engine step: gows emits passkey-request/passkey-confirmation, session status PASSKEY_REQUIRED, challenge stored and exposed via getPasskeyChallenge(). - REST: GET/POST /api/:session/auth/passkey, GET /api/:session/auth/passkey/confirmation, POST /api/:session/auth/passkey/confirm. - Webhooks: passkey.required (challenge) and passkey.confirmation.required (manual code case; most pairings auto-confirm server-side right after the assertion). - QR rotation no longer bounces PASSKEY_REQUIRED back to SCAN_QR_CODE. --- src/api/auth.controller.ts | 41 +++++++++++ src/core/abc/session.abc.ts | 16 ++++ src/core/engines/gows/session.gows.core.ts | 85 ++++++++++++++++++++++ src/structures/auth.dto.ts | 44 +++++++++++ src/structures/enums.dto.ts | 3 + 5 files changed, 189 insertions(+) diff --git a/src/api/auth.controller.ts b/src/api/auth.controller.ts index 70ca9fe2..1d80822c 100644 --- a/src/api/auth.controller.ts +++ b/src/api/auth.controller.ts @@ -19,6 +19,7 @@ import { SessionManager } from '../core/abc/manager.abc'; import { WhatsappSession } from '../core/abc/session.abc'; import { BufferResponseInterceptor } from '../nestjs/BufferResponseInterceptor'; import { + PasskeyAssertionRequest, QRCodeFormat, QRCodeQuery, QRCodeValue, @@ -68,6 +69,46 @@ class AuthController { ) { return session.requestCode(request.phoneNumber, request.method, request); } + + @Get('passkey') + @SessionApiParam + @ApiOperation({ + summary: 'Get the pending passkey (WebAuthn) challenge, if any.', + }) + getPasskey(@SessionParam session: WhatsappSession) { + return session.getPasskeyChallenge(); + } + + @Post('passkey') + @SessionApiParam + @ApiOperation({ + summary: 'Submit a WebAuthn passkey assertion to finish pairing.', + }) + submitPasskey( + @SessionParam session: WhatsappSession, + @Body() request: PasskeyAssertionRequest, + ) { + return session.sendPasskeyResponse(JSON.stringify(request)); + } + + @Get('passkey/confirmation') + @SessionApiParam + @ApiOperation({ + summary: + 'Get the pending passkey confirmation code, if any (manual confirmation-code case only).', + }) + getPasskeyConfirmation(@SessionParam session: WhatsappSession) { + return session.getPasskeyConfirmation(); + } + + @Post('passkey/confirm') + @SessionApiParam + @ApiOperation({ + summary: 'Confirm passkey pairing (only needed for the manual code case).', + }) + confirmPasskey(@SessionParam session: WhatsappSession) { + return session.confirmPasskey(); + } } export { AuthController }; diff --git a/src/core/abc/session.abc.ts b/src/core/abc/session.abc.ts index 9c23a584..6e20d206 100644 --- a/src/core/abc/session.abc.ts +++ b/src/core/abc/session.abc.ts @@ -450,6 +450,22 @@ export abstract class WhatsappSession { throw new NotImplementedByEngineError(); } + public getPasskeyChallenge(): any { + throw new NotImplementedByEngineError(); + } + + public async sendPasskeyResponse(responseJson: string): Promise { + throw new NotImplementedByEngineError(); + } + + public async confirmPasskey(): Promise { + throw new NotImplementedByEngineError(); + } + + public getPasskeyConfirmation(): { code: string; skipHandoffUX: boolean } | null { + throw new NotImplementedByEngineError(); + } + abstract getScreenshot(): Promise; public getSessionMeInfo(): MeInfo | null { diff --git a/src/core/engines/gows/session.gows.core.ts b/src/core/engines/gows/session.gows.core.ts index fafe7e52..62e664d0 100644 --- a/src/core/engines/gows/session.gows.core.ts +++ b/src/core/engines/gows/session.gows.core.ts @@ -271,6 +271,8 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { protected me: MeInfo | null; public session: messages.Session; protected presences: any; + protected passkeyChallenge: any = null; + protected passkeyConfirmation: { code: string; skipHandoffUX: boolean } | null = null; private local$ = new Subject(); @@ -419,6 +421,31 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { if (data.Event == 'success') { return; } + if (data.Event == 'passkey-request') { + // WhatsApp requires a passkey (WebAuthn) to finish pairing this account. + this.passkeyChallenge = data.PasskeyRequest?.PublicKey ?? null; + this.logger.info('Passkey required to finish pairing'); + this.status = WAHASessionStatus.PASSKEY_REQUIRED; + return; + } + if (data.Event == 'passkey-confirmation') { + const code = data.PasskeyConfirmation?.Code ?? null; + const skipHandoffUX = !!data.PasskeyConfirmation?.SkipHandoffUX; + this.logger.info({ code, skipHandoffUX }, 'Passkey confirmation code'); + if (skipHandoffUX) { + // WhatsApp says it's safe to confirm without showing the code to the + // operator first - send the confirmation right away. + this.confirmPasskey().catch((err) => + this.logger.error(err, 'Failed to auto-confirm passkey'), + ); + } else { + // Manual case: the operator must see the code, verify it matches the + // one shown on their phone, then confirm - surfaced via + // getPasskeyConfirmation()/POST .../auth/passkey/confirm. + this.passkeyConfirmation = { code, skipHandoffUX }; + } + return; + } if (data.Event != 'code') { this.logger.warn(data, 'Failed QR item event'); this.status = WAHASessionStatus.FAILED; @@ -430,6 +457,13 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { } this.qr.save(qr); this.printQR(this.qr); + if (this.status === WAHASessionStatus.PASSKEY_REQUIRED) { + // The underlying whatsmeow QR rotation keeps emitting fresh codes in + // parallel while the passkey challenge is pending (it doesn't know + // about the passkey step). Don't let that bounce the session back to + // SCAN_QR_CODE mid-flow — the operator is busy signing the passkey. + return; + } this.status = WAHASessionStatus.SCAN_QR_CODE; }); events.on(WhatsMeowEvent.PUSH_NAME_SETTING, (data) => { @@ -662,6 +696,36 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { ); this.events2.get(WAHAEvents.CALL_REJECTED).switch(callRejected$); + // + // Passkey + // + const passkeyRequired$ = all$.pipe( + onlyEvent(WhatsMeowEvent.QR_CHANNEL_ITEM), + filter((data: any) => data?.Event === 'passkey-request'), + map((data: any) => ({ + session: this.name, + url: 'https://web.whatsapp.com', + challenge: data?.PasskeyRequest?.PublicKey ?? null, + })), + ); + this.events2.get(WAHAEvents.PASSKEY_REQUIRED).switch(passkeyRequired$); + + const passkeyConfirmationRequired$ = all$.pipe( + onlyEvent(WhatsMeowEvent.QR_CHANNEL_ITEM), + filter( + (data: any) => + data?.Event === 'passkey-confirmation' && + !data?.PasskeyConfirmation?.SkipHandoffUX, + ), + map((data: any) => ({ + session: this.name, + code: data?.PasskeyConfirmation?.Code ?? null, + })), + ); + this.events2 + .get(WAHAEvents.PASSKEY_CONFIRMATION_REQUIRED) + .switch(passkeyConfirmationRequired$); + const presence$ = all$.pipe( onlyEvent(WhatsMeowEvent.PRESENCE), filter((event: any) => this.jids.include(event?.From)), @@ -849,6 +913,27 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { return { code: code }; } + public async sendPasskeyResponse(responseJson: string): Promise { + const request = new messages.PasskeyResponseRequest({ + session: this.session, + response_json: responseJson, + }); + await promisify(this.client.SubmitPasskeyResponse)(request); + } + + public async confirmPasskey(): Promise { + await promisify(this.client.ConfirmPasskey)(this.session); + this.passkeyConfirmation = null; + } + + public getPasskeyChallenge(): any { + return this.passkeyChallenge; + } + + public getPasskeyConfirmation(): { code: string; skipHandoffUX: boolean } | null { + return this.passkeyConfirmation; + } + async unpair() { await promisify(this.client.Logout)(this.session); } diff --git a/src/structures/auth.dto.ts b/src/structures/auth.dto.ts index e344dfb2..27073260 100644 --- a/src/structures/auth.dto.ts +++ b/src/structures/auth.dto.ts @@ -38,3 +38,47 @@ export class RequestCodeRequest { export class PairingCodeResponse { code: string; } + +export class PasskeyAssertionResponseData { + @ApiProperty({ + description: 'Base64url-encoded clientDataJSON from the authenticator.', + }) + clientDataJSON: string; + + @ApiProperty({ + description: 'Base64url-encoded authenticatorData from the authenticator.', + }) + authenticatorData: string; + + @ApiProperty({ + description: 'Base64url-encoded signature from the authenticator.', + }) + signature: string; + + @ApiProperty({ + description: 'Base64url-encoded user handle, if returned by the authenticator.', + required: false, + }) + userHandle?: string; +} + +export class PasskeyAssertionRequest { + @ApiProperty({ + description: 'Credential ID, as returned by navigator.credentials.get().toJSON().', + }) + id: string; + + @ApiProperty({ + description: 'Base64url-encoded raw credential ID.', + }) + rawId: string; + + @ApiProperty({ + description: 'Always "public-key".', + example: 'public-key', + }) + type: string; + + @ApiProperty({ type: PasskeyAssertionResponseData }) + response: PasskeyAssertionResponseData; +} diff --git a/src/structures/enums.dto.ts b/src/structures/enums.dto.ts index c40d2fa9..81f0e7f5 100644 --- a/src/structures/enums.dto.ts +++ b/src/structures/enums.dto.ts @@ -2,6 +2,8 @@ export const SECOND = 1000; export enum WAHAEvents { SESSION_STATUS = 'session.status', + PASSKEY_REQUIRED = 'passkey.required', + PASSKEY_CONFIRMATION_REQUIRED = 'passkey.confirmation.required', MESSAGE = 'message', MESSAGE_REACTION = 'message.reaction', MESSAGE_ANY = 'message.any', @@ -45,6 +47,7 @@ export enum WAHASessionStatus { STOPPED = 'STOPPED', STARTING = 'STARTING', SCAN_QR_CODE = 'SCAN_QR_CODE', + PASSKEY_REQUIRED = 'PASSKEY_REQUIRED', WORKING = 'WORKING', FAILED = 'FAILED', }