패스키 인증

이 가이드에서는 명시적 버튼 트리거와 원활한 브라우저 자동 완성 추천 (조건부 UI)을 통해 검색 가능한 사용자 인증 정보를 사용하여 재방문자 인증을 구현하는 방법을 자세히 설명합니다.

서버 측

옵션 생성

표준에 따라 검증된 라이브러리를 사용하여 WebAuthn 요청 매개변수를 생성하는 엔드포인트를 만듭니다.

  1. 사전 정의된 RP ID 사용: 사전 정의된 적절한 RP ID를 상수 문자열로 사용합니다.
  2. 챌린지 생성: 엔트로피가 높고 암호화 방식으로 안전한 무작위 버퍼를 생성하고, 이를 사용자의 세션에 안전하게 저장하고, Base64URL로 인코딩합니다.
  3. 검색 가능한 사용자 인증 정보 매핑: allowCredentials에 빈 배열 []를 지정합니다. 이렇게 하면 검색 가능한 사용자 인증 정보가 요청됩니다. 즉, 사용자가 먼저 사용자 이름을 입력할 필요가 없으며 패스키 제공업체가 사용 가능한 계정을 표시합니다.
  4. 사용자 인증 수준: userVerification: "preferred"로 설정합니다(또는 회사 규정 준수 정책에서 명시적으로 요구하는 경우 "required").
    • 요청된 userVerification 제약 조건 수준은 쿼리 문자열을 통해 클라이언트에서 다시 전달되는 것이 아니라 옵션 엔드포인트의 서버 세션 레코드 내에 유지되어야 합니다(MUST). 이렇게 하면 인증 엔드포인트가 클라이언트 조작의 위험 없이 엄격한 일치 제약 조건을 안전하게 적용할 수 있습니다.
// Options generation example (discoverable flow)
const options = {
  challenge: serverGeneratedBase64UrlChallenge, // High-entropy random challenge stored in session
  rpId: "example.com",
  allowCredentials: [], // Request discoverable passkeys
  userVerification: "preferred",
};

// Persist expected UV level to user session
req.session.expectedUserVerification = "preferred";

인증 엔드포인트

클라이언트에서 반환한 어설션을 안전하게 확인하여 사용자를 인증합니다.

  1. 세션 챌린지 검증: 클라이언트 응답과 세션에 저장된 예상 챌린지 간에 엄격한 챌린지 일치를 적용합니다.
  2. UV 환경설정 적용:
    • 세션의 expectedUserVerification가 "preferred"를 요청한 경우 requireUserVerification: false를 서버 측 확인 라이브러리에 전달하여 UV가 없는 인증자 (예: 인증자 화면 잠금 사용 중지됨)를 허용합니다. "required" 요청 시 생체 인식/PIN 입력을 엄격하게 적용합니다.
  3. 서버 오류 404 정리: 클라이언트에서 반환한 사용자 인증 정보 ID를 데이터베이스에서 찾을 수 없는 경우 클라이언트가 Signal API를 트리거할 수 있도록 명시적인 HTTP 404 오류를 반환합니다.

클라이언트 측 로직

HTML 양식 주석

사용자 이름 및 비밀번호 입력에 주석을 달아 조건부 UI를 기본적으로 활용합니다. 자동 완성 토큰은 webauthn 사양 매개변수를 결합하고, 자동 초점은 입력에 초점이 맞춰지면 브라우저 자동 완성 팝업을 즉시 트리거합니다.

<!-- Autocomplete tokens must contain webauthn space-separated -->
<form id="signin-form">
  <input
    type="text"
    name="username"
    autocomplete="username webauthn"
    autofocus
    data-testid="username-field"
  />
  <input type="password" name="password" autocomplete="current-password" />
  <button type="submit">Sign in</button>
</form>

명시적 버튼 흐름

사용자가 '패스키로 로그인' 버튼을 클릭하면 패스키 인증을 트리거합니다. 패스키 프롬프트를 호출하기 전에 진행 중인 양식 자동 완성 (조건부 가져오기) 호출을 중단합니다.

조건부 미디에이션 흐름 (양식 자동 완성)

페이지 로드 시 양식 자동 완성 추천을 활성화하여 사용자가 로그인 입력란에 포커스를 맞출 때 패스키 인증을 기본적으로 제공합니다.

  1. 기능 감지: 페이지 로드 시 PublicKeyCredential.getClientCapabilities()를 호출하고 conditionalGet를 사용할 수 없는 경우 패스키로 로그인 건너뛰기를 호출합니다.
  2. 디코딩 옵션: 가져온 사용자 인증 정보 JSON 객체를 PublicKeyCredential.parseRequestOptionsFromJSON()로 디코딩합니다.
  3. 조건부 가져오기 호출: mediation: "conditional"를 사용하여 navigator.credentials.get()를 호출하고 AbortController 신호를 전달합니다. 이렇게 하면 패스키 대화상자를 렌더링하지 않고 자동 완성이 자동으로 등록됩니다.
  4. Try/Catch 예외 분리: try/catch 블록에서 navigator.credentials.get 호출을 래핑합니다.
    • NotAllowedError: 사용자가 패스키 로그인 프롬프트를 취소했거나 시간이 초과되었습니다.
    • AbortError: 인증 요청이 프로그래매틱 방식으로 취소되었습니다.
  5. Call Signal API: try/catch 블록에서 서버 확인 fetch() 호출을 래핑합니다.
    • 사용자가 무엇이 잘못되었는지 이해할 수 있도록 오류 메시지를 표시합니다.
    • 서버가 HTTP 상태 404 (사용자 인증 정보를 찾을 수 없음)로 명시적으로 응답하고 사용자가 인증되지 않은 경우에만 signalUnknownCredential()를 호출합니다.
    • signalUnknownCredential()에 전달된 credentialId 매개변수는 원시 ArrayBuffer 객체 credential.rawId가 아닌 Base64URL로 인코딩된 사용자 인증 정보 ID 문자열(예: encoded.id)이어야 합니다(MUST).
  6. 응답 인코딩: 사용자 인증 정보 AuthenticatorAssertionResponse를 .toJSON()로 인코딩한 후 확인을 위해 서버로 전송합니다.
// optionsFetch and loginVerifyFetch are app-defined HTTP methods
import { optionsFetch, loginVerifyFetch } from "./api.js";

let autofillAbortController = new AbortController();

async function initializeConditionalAutofill() {
  // Feature detect Conditional Get autofill support
  const capabilities = await PublicKeyCredential.getClientCapabilities();
  if (capabilities.conditionalGet === true) {
    const loginOptionsJSON = await optionsFetch();
    const publicKey =
      PublicKeyCredential.parseRequestOptionsFromJSON(loginOptionsJSON);

    try {
      // Initiate Conditional UI form autofill suggestions
      const credential = await navigator.credentials.get({
        publicKey,
        signal: autofillAbortController.signal,
        mediation: "conditional",
      });

      // Segregated verification fetch
      const encoded = credential.toJSON();
      const response = await loginVerifyFetch(encoded);
      if (!response.ok && response.status === 404) {
        // Note: this code path runs pre-authentication, satisfying the unauth precondition
        if (PublicKeyCredential.signalUnknownCredential) {
          await PublicKeyCredential.signalUnknownCredential({
            rpId, // RP ID must match the one defined on the server
            credentialId: encoded.id,
          });
        }
      }
    } catch (err) {
      // Silently swallow expected client WebAuthn exceptions
      if (["NotAllowedError", "AbortError"].includes(err.name)) {
        return;
      }
      console.error("Unexpected conditional get error:", err);
    }
  }
}

async function triggerButtonAuthentication() {
  // Abort any pending Conditional Get call to prevent passkey prompt collisions
  autofillAbortController.abort();
  autofillAbortController = new AbortController(); // Reset controller for next triggers

  const loginOptionsJSON = await optionsFetch();
  const publicKey =
    PublicKeyCredential.parseRequestOptionsFromJSON(loginOptionsJSON);

  let credential;
  try {
    // Passkey explicit prompt trigger
    credential = await navigator.credentials.get({
      publicKey,
      signal: autofillAbortController.signal,
    });
  } catch (err) {
    if (err.name === "NotAllowedError") {
      console.log("User cancelled passkey login.");
    } else if (err.name === "AbortError") {
      console.log("The authentication operation was aborted.");
    }
    // Re-arm Conditional autofill Suggestions after cancelled explicit button prompts
    initializeConditionalAutofill();
    return; // Safe exit
  }

  // Segregated verification try/catch (HTTP 404 trigger)
  const encoded = credential.toJSON();
  try {
    const response = await loginVerifyFetch(encoded);
    if (!response.ok && response.status === 404) {
      // Note: this code path runs pre-authentication, satisfying the unauth precondition
      await PublicKeyCredential.signalUnknownCredential({
        rpId, // RP ID must match the one defined on the server
        credentialId: encoded.id, // Base64URL-encoded credential ID
      });
    }
  } catch (serverErr) {
    console.error("Verification request error:", serverErr);
  }
}

// Trigger Conditional Get on load
window.addEventListener("DOMContentLoaded", initializeConditionalAutofill);

대체 전략

패스키 기능 감지 대체

항상 'webauthn-polyfills'를 설치하고 컨텍스트에서 가져옵니다. PublicKeyCredential가 지원되는 한 PublicKeyCredential.getClientCapabilities도 지원됩니다.

import 'webauthn-polyfills';

신호 API 동기화 대체

WebAuthn Signal API (webauthn-signals)는 비밀번호 관리자를 서버 사용자 인증 정보 상태와 동기화하는 데 사용되는 점진적 최적화입니다.

  • 대체 환경: if (PublicKeyCredential.signalUnknownCredential)를 통해 제한됩니다. 지원되지 않는 경우 백그라운드 인증 동기화는 브라우저 예외를 발생시키지 않고 정상적으로 우회됩니다.

간편한 JSON 직렬화 대체

항상 'webauthn-polyfills'를 설치하고 컨텍스트에서 가져옵니다. PublicKeyCredential가 지원되는 한 PublicKeyCredential.parseRequestOptionsFromJSON 및 PublicKeyCredential.prototype.toJSON도 지원됩니다.

import 'webauthn-polyfills';