26 10 회고 - ChoDragon9/posts GitHub Wiki

SSO 3rd-party Cookie 문제 해결

배경

  • BO/FO 전용 SSO에서 3rd-party Cookie 사용
  • 브라우저에서 3rd-party 차단으로 로그인 불가
    • 브라우저 설정 변경은 사용자에게 요구하기 어려움

방안: DPoP(Demonstrating Proof-of-Possession, RFC 9449) / SSO 및 OAuth 2.0/OIDC

Gemini Flash 3.8로 작성된 글

1. DPoP을 왜 쓰게 되었는가

  • Bearer 토큰(RFC 6750)의 한계: Bearer 토큰은 '무기명 채권'과 같아서 토큰을 제시하는 자가 누구인지 검증하지 않습니다. 네트워크 스니핑, 프록시/APM 로그 노출, 브라우저 XSS 등을 통해 문자열이 한 번 탈취되면 토큰 만료 전까지 재사용(Replay Attack)을 차단할 수 없습니다.
  • mTLS(RFC 8705)의 제약: 전송 계층(L4) 기반의 mTLS는 강력하지만 브라우저 자바스크립트가 OS/TLS 스택의 디지털 인증서를 제어할 수 없고, API 게이트웨이/WAF 종단 시 인증서 패스스루 인프라 구성 및 수많은 단말에 대한 X.509 발급·관리 비용이 과도합니다.
  • 도입 배경: 인프라 전송 계층의 제약 없이 애플리케이션 계층(L7, HTTP Header)에서 비대칭 암호화를 통해 토큰을 특정 클라이언트에 결속(Sender-Constraining)시키기 위해 도입되었습니다.

2. 정의

DPoP(RFC 9449)은 OAuth 2.0 클라이언트가 비대칭 키 쌍(ECDSA/RSA)을 자체 생성하여 보관하고, 매 HTTP 요청마다 해당 개인키의 소유권을 입증하는 단기 증명 토큰(DPoP Proof JWT)을 헤더에 동봉하는 메커니즘입니다. 인가 서버는 토큰 발행 시 클라이언트 공개키의 고유 지문(jkt)을 토큰에 각인하여 발급하므로, 공격자가 토큰 문자열을 훔쳐도 클라이언트의 개인키가 없으면 토큰을 사용할 수 없습니다.

3. 용어 정의

  • DPoP Proof JWT: 매 요청마다 클라이언트 개인키로 서명하여 DPoP 헤더에 전송하는 일회성 단기 유효 JWT입니다 (typ: "dpop+jwt").
  • Sender-Constrained Token: 특정 키를 소유한 클라이언트만 사용할 수 있도록 암호학적으로 바인딩된 토큰입니다.
  • jkt (JWK SHA-256 Thumbprint): RFC 7638 규격에 따라 클라이언트의 공개키(JWK)를 정규화하여 생성한 SHA-256 해시 지문입니다. 토큰의 cnf.jkt 클레임에 저장됩니다.
  • ath (Access Token Hash): 보호된 API 호출 시 Proof 내에 포함되는 제시된 액세스 토큰 문자열의 Base64URL(SHA-256) 해시값으로, Proof와 토큰 간의 교차 혼용을 방지합니다.
  • htm / htu: DPoP Proof가 유효한 대상 HTTP 메서드(GET, POST 등) 및 쿼리/프래그먼트를 제외한 정규화된 대상 URI입니다.
  • DPoP-Nonce: 서버가 클라이언트에 전달하는 단기 챌린지 난수로, 시간 오차 극복 및 엄격한 재생 공격 방지에 사용됩니다.

4. 표준 현황

  • IETF 표준화: 2023년 9월 공식 표준 트랙인 Proposed Standard(RFC 9449)로 정식 발간되었습니다.
  • 글로벌 규격 채택: 오픈뱅킹/핀테크 고보안 프로파일인 FAPI 2.0에서 mTLS의 공식 대체 옵션으로 채택되었으며, 브라우저 앱 보안 모범 규준(draft-ietf-oauth-browser-based-apps)의 핵심 요구사항으로 편입되었습니다.
  • 주요 플랫폼 지원:
    • Keycloak: 26.4 버전부터 공식 지원(GA)하며 클라이언트별 DPoP 강제 설정 및 내장 REST API 보호를 제공합니다.
    • Okta / Auth0: 플랫폼 차원 지원 및 SPA SDK(@auth0/auth0-spa-js 등)를 통해 비대칭 키 생성, IndexedDB 저장, Nonce 자동 핸들링을 지원합니다.
    • Google: 장기 유효성을 지닌 OAuth 리프레시 토큰의 탈취 방지를 위해 DPoP 바인딩을 적용하였습니다.
    • Spring Security: 7.x 및 최신 버전에서 인가/리소스 서버용 DPoP 필터와 DPoP 토큰 교환(RFC 8693)을 지원합니다.

5. 구현 코드

(1) 클라이언트: Web Crypto API 기반 키 생성 및 Proof 서명 (TypeScript)

클라이언트는 개인키를 브라우저 스크립트로 추출할 수 없도록 extractable: false로 생성해야 합니다.

// 1. 비대칭 키 쌍 생성 (비추출성 보장)
export async function initDPoP() {
  const keyPair = await window.crypto.subtle.generateKey(
    { name: "ECDSA", namedCurve: "P-256" },
    false, // extractable: false로 개인키 노출 차단
    ["sign", "verify"]
  );
  const jwk = await window.crypto.subtle.exportKey("jwk", keyPair.publicKey);
  delete jwk.key_ops;
  delete jwk.ext;
  return { privateKey: keyPair.privateKey, publicKeyJwk: jwk };
}

// 2. Base64URL 헬퍼
const toB64Url = (buf: ArrayBuffer) =>
  btoa(String.fromCharCode(...new Uint8Array(buf)))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

// 3. DPoP Proof JWT 생성
export async function createDPoPProof(
  keys: { privateKey: CryptoKey; publicKeyJwk: JsonWebKey },
  method: string,
  urlStr: string,
  accessToken?: string,
  nonce?: string
): Promise<string> {
  const header = { typ: "dpop+jwt", alg: "ES256", jwk: keys.publicKeyJwk };
  const u = new URL(urlStr);
  const payload: Record<string, any> = {
    jti: crypto.randomUUID(),
    htm: method.toUpperCase(),
    htu: `${u.origin}${u.pathname}`,
    iat: Math.floor(Date.now() / 1000),
  };

  if (accessToken) {
    const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(accessToken));
    payload.ath = toB64Url(hash);
  }
  if (nonce) payload.nonce = nonce;

  const enc = new TextEncoder();
  const input = `${toB64Url(enc.encode(JSON.stringify(header)))}.${toB64Url(enc.encode(JSON.stringify(payload)))}`;
  const sig = await crypto.subtle.sign(
    { name: "ECDSA", hash: { name: "SHA-256" } },
    keys.privateKey,
    enc.encode(input)
  );

  return `${input}.${toB64Url(sig)}`;
}

(2) 리소스 서버: Node.js 기반 Proof 검증 로직 요약

서버는 DPoP 헤더의 서명, 대상 HTTP 메서드/URI, 액세스 토큰 해시(ath), 그리고 토큰 내 지문(cnf.jkt)의 일치 여부를 모두 확인합니다.

import * as crypto from "crypto";

export function verifyDPoP(
  dpopHeader: string,
  rawToken: string,
  method: string,
  reqUrl: string,
  tokenCnfJkt: string
) {
  const [hB64, pB64, sigB64] = dpopHeader.split(".");
  const header = JSON.parse(Buffer.from(hB64, "base64url").toString("utf8"));
  const payload = JSON.parse(Buffer.from(pB64, "base64url").toString("utf8"));
  const signature = Buffer.from(sigB64, "base64url");

  // 1. 헤더 기본 검증
  if (header.typ !== "dpop+jwt" || header.alg !== "ES256" || !header.jwk) {
    throw new Error("Invalid DPoP header");
  }

  // 2. 비대칭 서명 검증
  const pubKey = crypto.createPublicKey({ key: header.jwk, format: "jwk" });
  const validSig = crypto.verify(
    "sha256",
    Buffer.from(`${hB64}.${pB64}`, "utf8"),
    { key: pubKey, dsaEncoding: "ieee-p1363" },
    signature
  );
  if (!validSig) throw new Error("Signature verification failed");

  // 3. htm, htu 및 시간 오차 검증
  const targetHtu = `${new URL(reqUrl).origin}${new URL(reqUrl).pathname}`;
  if (payload.htm !== method.toUpperCase() || payload.htu !== targetHtu) {
    throw new Error("Method or URI mismatch");
  }

  // 4. ath (Access Token Hash) 대조
  const calcAth = crypto.createHash("sha256").update(rawToken, "ascii").digest("base64url");
  if (payload.ath !== calcAth) throw new Error("ath mismatch");

  // 5. 토큰 바인딩 대조 (RFC 7638 SHA-256 Thumbprint)
  const canonicalJwk = JSON.stringify({
    crv: header.jwk.crv,
    kty: header.jwk.kty,
    x: header.jwk.x,
    y: header.jwk.y,
  });
  const calcJkt = crypto.createHash("sha256").update(canonicalJwk).digest("base64url");
  if (tokenCnfJkt !== calcJkt) throw new Error("Token jkt thumbprint mismatch");

  return true;
}
⚠️ **GitHub.com Fallback** ⚠️