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:
1 parent
982092cf2b
commit
3618b92c3e
5 files changed
+189
No files matched your search
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user