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.
This commit is contained in:
Berg Pinheiro authored and devlikepro committed 2026-07-15 20:04:27 +07:00
1 parent 982092cf2b
commit 3618b92c3e
5 files changed
+189

No files matched your search

+41
View File
@@ -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 };
+16
View File
@@ -450,6 +450,22 @@ export abstract class WhatsappSession {
throw new NotImplementedByEngineError();
}
public getPasskeyChallenge(): any {
throw new NotImplementedByEngineError();
}
public async sendPasskeyResponse(responseJson: string): Promise<void> {
throw new NotImplementedByEngineError();
}
public async confirmPasskey(): Promise<void> {
throw new NotImplementedByEngineError();
}
public getPasskeyConfirmation(): { code: string; skipHandoffUX: boolean } | null {
throw new NotImplementedByEngineError();
}
abstract getScreenshot(): Promise<Buffer>;
public getSessionMeInfo(): MeInfo | null {
@@ -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<EnginePayload>();
@@ -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<void> {
const request = new messages.PasskeyResponseRequest({
session: this.session,
response_json: responseJson,
});
await promisify(this.client.SubmitPasskeyResponse)(request);
}
public async confirmPasskey(): Promise<void> {
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);
}
+44
View File
@@ -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;
}
+3
View File
@@ -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',
}