import type {
    DeleteWebAuthnCredentialRequest,
    DeleteWebAuthnCredentialResult,
    RedirectResult,
    ResponseResult,
    SessionMetadataPayload,
    WebAuthnCredentialViewModel,
} from "../models/AuthModels";
import { ApiHttpClient } from "./ApiHttpClient";
import Bowser from "bowser";

const WEBAUTHN_TIMEOUT_MS = 60000;

export class WebAuthnService {
    constructor(private readonly http = new ApiHttpClient()) {}

    async list() {
        return await this.http.get<WebAuthnCredentialViewModel[]>("/api/auth/webauthn/credentials");
    }

    async register(displayName = this.createDisplayName()) {
        const optionsResult = await this.http.post<PublicKeyCredentialCreationOptions>(
            "/api/auth/webauthn/register/options",
            { displayName },
        );
        if (!optionsResult.success || !optionsResult.data) {
            return {
                success: false,
                code: optionsResult.code,
                message: optionsResult.message,
            };
        }

        const credential = await this.createCredential(
            this.decodeCreationOptions(optionsResult.data),
        );
        return await this.http.post("/api/auth/webauthn/register/complete", {
            displayName,
            credential: this.serializeCredential(credential),
        });
    }

    async signIn(
        account: string | undefined,
        returnUrl: string | undefined,
        remember: boolean,
        sessionInfo?: SessionMetadataPayload,
    ): Promise<ResponseResult<RedirectResult>> {
        const optionsResult = await this.http.post<PublicKeyCredentialRequestOptions>(
            "/api/auth/webauthn/assertion/options",
            { account },
        );
        if (!optionsResult.success || !optionsResult.data) {
            return {
                success: false,
                code: optionsResult.code,
                message: optionsResult.message,
            };
        }

        const credential = await this.getCredential(this.decodeRequestOptions(optionsResult.data));
        return await this.http.post<RedirectResult>("/api/auth/webauthn/assertion/complete", {
            credential: this.serializeCredential(credential),
            challenge: optionsResult.data.challenge,
            returnUrl,
            remember,
            sessionInfo,
        });
    }

    async deleteCredential(
        credentialId: string,
        request: DeleteWebAuthnCredentialRequest = {},
    ): Promise<ResponseResult<DeleteWebAuthnCredentialResult>> {
        return await this.http.post<DeleteWebAuthnCredentialResult>(
            `/api/auth/webauthn/credentials/${encodeURIComponent(credentialId)}/delete`,
            request,
        );
    }

    /** 用于受 MFA 保护的流程:从服务端下发的参数发起浏览器断言(不做网络请求)。 */
    async createAssertion(assertionOptions: PublicKeyCredentialRequestOptions) {
        const credential = await this.getCredential(this.decodeRequestOptions(assertionOptions));
        return this.serializeCredential(credential);
    }

    isWebAuthnSupported() {
        return (
            typeof window !== "undefined" &&
            window.isSecureContext &&
            "PublicKeyCredential" in window &&
            "credentials" in navigator &&
            typeof navigator.credentials?.create === "function" &&
            typeof navigator.credentials?.get === "function"
        );
    }

    createDisplayName() {
        const parser = Bowser.getParser(navigator.userAgent);
        const browser = parser.getBrowser();
        const os = parser.getOS();
        const platform = parser.getPlatform();
        const parts = [
            platform.model || os.name || this.toDeviceTypeName(platform.type),
            browser.name,
            this.formatDisplayDate(new Date()),
        ].filter((value): value is string => Boolean(value));

        return parts.join(" · ");
    }

    toFriendlyError(error: unknown, context: { discoverable?: boolean } = {}) {
        if (!(error instanceof DOMException)) {
            return error instanceof Error ? error.message : "Passkey 操作失败,请重试";
        }

        const discoverableMessage =
            "Passkey 操作未完成。当前是无账号发现式登录,浏览器会列出所有 localhost 通行密钥;如果本机存在多个旧凭据,请先输入账号再登录,或删除旧的 localhost Passkey 后重试";

        switch (error.name) {
            case "NotAllowedError":
                if (context.discoverable) {
                    return discoverableMessage;
                }
                return "Passkey 操作已取消,或当前浏览器没有这个站点的可用凭证;如果已绑定,请先输入账号再试";
            case "TimeoutError":
            case "AbortError":
                if (context.discoverable) {
                    return discoverableMessage;
                }
                return "Passkey 操作超时,请重新点击后完成系统验证";
            case "SecurityError":
                return "当前站点或浏览器安全上下文不允许使用 Passkey";
            case "InvalidStateError":
                return "该设备可能已经绑定过 Passkey,请换一个凭证或刷新列表";
            case "NotSupportedError":
                return "当前浏览器或认证器不支持本次 Passkey 操作";
            case "UnknownError":
                if (context.discoverable) {
                    return discoverableMessage;
                }
                return "Passkey 操作遇到浏览器或系统的临时错误,请关闭弹窗后重新点击;如果连续失败,请重新绑定 Passkey";
            default:
                return error.message || "Passkey 操作失败,请重试";
        }
    }

    private decodeCreationOptions(options: PublicKeyCredentialCreationOptions) {
        return {
            ...options,
            challenge: this.base64UrlToArrayBuffer(options.challenge as unknown as string),
            user: {
                ...options.user,
                id: this.base64UrlToArrayBuffer(options.user.id as unknown as string),
            },
            excludeCredentials: options.excludeCredentials?.map((credential) => ({
                ...credential,
                id: this.base64UrlToArrayBuffer(credential.id as unknown as string),
            })),
        };
    }

    decodeRequestOptions(options: PublicKeyCredentialRequestOptions) {
        const decoded = {
            ...options,
            challenge: this.base64UrlToArrayBuffer(options.challenge as unknown as string),
        };

        if (options.allowCredentials?.length) {
            decoded.allowCredentials = options.allowCredentials.map((credential) => ({
                ...credential,
                id: this.base64UrlToArrayBuffer(credential.id as unknown as string),
            }));
        } else {
            delete decoded.allowCredentials;
        }

        return decoded;
    }

    private async createCredential(publicKey: PublicKeyCredentialCreationOptions) {
        const credential = await this.withAbort((signal) =>
            navigator.credentials.create({
                publicKey,
                signal,
            }),
        );
        if (!credential) {
            throw new DOMException("No credential returned", "NotAllowedError");
        }

        return credential;
    }

    private async getCredential(publicKey: PublicKeyCredentialRequestOptions) {
        const credential = await this.withAbort((signal) =>
            navigator.credentials.get({
                publicKey,
                signal,
            }),
        );
        if (!credential) {
            throw new DOMException("No credential returned", "NotAllowedError");
        }

        return credential;
    }

    private async withAbort<T>(operation: (signal: AbortSignal) => Promise<T>) {
        const controller = new AbortController();
        let timer = 0;
        const timeout = new Promise<never>((_, reject) => {
            timer = window.setTimeout(() => {
                controller.abort();
                reject(new DOMException("Passkey operation timed out", "TimeoutError"));
            }, WEBAUTHN_TIMEOUT_MS);
        });

        try {
            return await Promise.race([operation(controller.signal), timeout]);
        } finally {
            window.clearTimeout(timer);
        }
    }

    private base64UrlToArrayBuffer(value: string) {
        const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
        const padded = normalized.padEnd(
            normalized.length + ((4 - (normalized.length % 4)) % 4),
            "=",
        );
        const binary = atob(padded);
        const buffer = new ArrayBuffer(binary.length);
        const bytes = new Uint8Array(buffer);
        for (let index = 0; index < binary.length; index += 1) {
            bytes[index] = binary.charCodeAt(index);
        }
        return buffer;
    }

    serializeCredential(credential: Credential | null) {
        if (!this.isPublicKeyCredential(credential)) {
            return credential;
        }

        const response = credential.response;
        const result: Record<string, unknown> = {
            id: credential.id,
            rawId: this.arrayBufferToBase64Url(credential.rawId),
            type: credential.type,
            clientExtensionResults: credential.getClientExtensionResults(),
            authenticatorAttachment: credential.authenticatorAttachment,
        };

        if (this.isAttestationResponse(response)) {
            result.response = {
                attestationObject: this.arrayBufferToBase64Url(response.attestationObject),
                clientDataJSON: this.arrayBufferToBase64Url(response.clientDataJSON),
                transports: response.getTransports(),
            };
            return result;
        }

        if (this.isAssertionResponse(response)) {
            result.response = {
                authenticatorData: this.arrayBufferToBase64Url(response.authenticatorData),
                clientDataJSON: this.arrayBufferToBase64Url(response.clientDataJSON),
                signature: this.arrayBufferToBase64Url(response.signature),
                userHandle: response.userHandle
                    ? this.arrayBufferToBase64Url(response.userHandle)
                    : null,
            };
            return result;
        }

        throw new Error("Passkey 返回数据格式无效");
    }

    private arrayBufferToBase64Url(value: ArrayBuffer) {
        const bytes = new Uint8Array(value);
        let binary = "";
        for (const byte of bytes) {
            binary += String.fromCharCode(byte);
        }
        return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
    }

    private toDeviceTypeName(deviceType: string | undefined) {
        switch (deviceType) {
            case "mobile":
                return "Mobile";
            case "tablet":
                return "Tablet";
            case "tv":
                return "TV";
            default:
                return "Desktop";
        }
    }

    private formatDisplayDate(value: Date) {
        const pad = (part: number) => String(part).padStart(2, "0");
        return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(
            value.getDate(),
        )} ${pad(value.getHours())}:${pad(value.getMinutes())}`;
    }

    private isPublicKeyCredential(
        credential: Credential | null,
    ): credential is PublicKeyCredential {
        return (
            credential !== null &&
            credential.type === "public-key" &&
            "rawId" in credential &&
            "response" in credential &&
            typeof (credential as PublicKeyCredential).getClientExtensionResults ===
                "function"
        );
    }

    private isAttestationResponse(
        response: AuthenticatorResponse,
    ): response is AuthenticatorAttestationResponse {
        return "attestationObject" in response;
    }

    private isAssertionResponse(
        response: AuthenticatorResponse,
    ): response is AuthenticatorAssertionResponse {
        return "authenticatorData" in response && "signature" in response;
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

WebAuthn 服务代码解析

这是一个完整的 WebAuthn(Web Authentication API)服务类,用于实现基于 Passkey 的无密码认证功能。以下是详细解析:


核心功能概览

这个服务封装了 WebAuthn 的完整生命周期:

  1. 凭证注册(Register)- 绑定新的 Passkey
  2. 身份断言(Sign In)- 使用 Passkey 登录
  3. 凭证管理(List/Delete)- 查看和删除已绑定的凭证
  4. 错误处理 - 友好的用户提示

主要方法详解

1. 凭证注册流程 (register)

async register(displayName = this.createDisplayName())
  • 获取服务器下发的创建选项(challenge、用户信息等)
  • 调用浏览器 API 创建新凭证
  • 将凭证数据序列化后发送给服务器完成注册
  • 自动生成设备显示名称(如 "Desktop · Chrome · 2024-01-15 10:30")

2. 登录流程 (signIn)

async signIn(account, returnUrl, remember, sessionInfo)
  • 支持两种模式:
    • 账号绑定模式:传入 account,只验证该用户的凭证
    • 可发现模式:不传 account,浏览器列出所有可用凭证
  • 获取服务器 challenge → 用户验证 → 提交断言结果
  • 支持"记住我"和会话元数据传递

3. 凭证管理

  • list() - 获取当前用户所有绑定的 Passkey
  • deleteCredential() - 删除指定凭证

4. MFA 场景 (createAssertion)

用于多因素认证流程,服务器下发 challenge 后本地完成验证,无需额外网络请求。


关键技术处理

数据编码转换

WebAuthn API 使用 ArrayBuffer,而网络传输使用 Base64URL:

// Base64URL → ArrayBuffer(用于浏览器 API)
base64UrlToArrayBuffer(value: string)

// ArrayBuffer → Base64URL(用于网络传输)
arrayBufferToBase64Url(value: ArrayBuffer)

凭证序列化

将浏览器返回的复杂对象转换为可传输的 JSON:

serializeCredential(credential)

处理两种响应类型:

  • AttestationResponse(注册)- 包含公钥、设备信息
  • AssertionResponse(登录)- 包含签名、用户句柄

超时控制

使用 AbortController 实现 60 秒超时:

private async withAbort<T>(operation)

防止用户长时间未操作导致界面卡死。


错误处理与用户体验

toFriendlyError 方法

将 WebAuthn 的技术性错误转换为用户友好提示:

DOMException 错误用户提示
NotAllowedError"操作已取消,或没有可用凭证"
TimeoutError"操作超时,请重新验证"
InvalidStateError"设备已绑定,请换一个凭证"
SecurityError"站点安全上下文不允许"

特殊处理可发现模式(discoverable)的错误提示。


环境检测

isWebAuthnSupported

检查浏览器兼容性:

  • 必须是 HTTPS(安全上下文)
  • 支持 PublicKeyCredential
  • 存在 navigator.credentials API

设备信息生成

使用 Bowser 库解析 UserAgent:

createDisplayName() // 示例输出: "iPhone · Safari · 2024-01-15 10:30"

类型守卫(Type Guards)

三个私有方法确保类型安全:

isPublicKeyCredential()      // 验证是否为 PublicKeyCredential
isAttestationResponse()      // 判断是注册响应
isAssertionResponse()        // 判断是断言响应

安全特性

  1. Challenge 防重放 - 每次操作使用服务器下发的一次性 challenge
  2. 超时保护 - 60 秒自动中止操作
  3. HTTPS 强制 - 仅在安全上下文工作
  4. 凭证隔离 - excludeCredentials 防止重复绑定

依赖项

  • ApiHttpClient - 封装的 HTTP 客户端
  • Bowser - UserAgent 解析库
  • 浏览器原生 navigator.credentials API

典型使用场景

const service = new WebAuthnService();

// 注册 Passkey
await service.register("我的 iPhone");

// 登录(账号绑定模式)
await service.signIn("user@example.com", "/dashboard", true);

// 登录(可发现模式)
await service.signIn(undefined, "/", false);

// 删除凭证
await service.deleteCredential("credential-id-123");

总结

这是一个生产级别的 WebAuthn 实现,具有:

  • ✅ 完整的注册/登录流程
  • ✅ 友好的错误处理
  • ✅ 完善的类型安全
  • ✅ 良好的用户体验(自动生成设备名、超时控制)
  • ✅ 适配多种场景(MFA、可发现模式)

代码遵循现代 TypeScript 最佳实践,适合作为 Passkey 功能的参考实现。

评论加载中...