Crypto DPoP
통합 가이드
Axios 인터셉터, 토큰 갱신, Nonce 처리 등 실제 통합 패턴
통합 가이드
Axios 인터셉터 패턴
API 클라이언트에 DPoP를 통합하는 가장 일반적인 패턴은 Axios 요청 인터셉터를 사용하는 것입니다. 모든 요청에 자동으로 DPoP 헤더를 추가합니다.
import axios from 'axios';import ExpoCryptoDpop from 'expo-crypto-dpop';const apiClient = axios.create({baseURL: 'https://api.example.com',});// 요청 인터셉터: 모든 요청에 DPoP 증명 자동 추가apiClient.interceptors.request.use(async (config) => {const method = config.method?.toUpperCase() ?? 'GET';const url = new URL(config.url ?? '', config.baseURL);// htu는 쿼리 파라미터와 프래그먼트를 제외한 URIconst htu = `${url.origin}${url.pathname}`;const accessToken = getStoredAccessToken(); // 저장된 액세스 토큰 조회const dpopProof = await ExpoCryptoDpop.createProof({htm: method,htu,accessToken: accessToken ?? undefined,});config.headers['DPoP'] = dpopProof;if (accessToken) {config.headers['Authorization'] = `DPoP ${accessToken}`;}return config;});
htu는 RFC 9449에 따라 쿼리 파라미터(?foo=bar)와 프래그먼트(#section)를 포함하지 않아야 합니다. URL 객체의 origin + pathname만 사용하세요.
Nonce 처리 (use_dpop_nonce)
일부 서버는 재전송 공격 방지를 위해 DPoP Nonce를 요구합니다. 서버가 nonce를 요구하면 401 응답과 함께 WWW-Authenticate 헤더에 nonce 값을 포함합니다.
import axios from 'axios';import ExpoCryptoDpop from 'expo-crypto-dpop';let currentNonce: string | null = null;// 응답 인터셉터: nonce 처리 및 자동 재시도apiClient.interceptors.response.use((response) => response,async (error) => {const { config, response } = error;if (response?.status === 401) {const wwwAuth = response.headers['www-authenticate'] ?? '';// use_dpop_nonce 오류 처리if (wwwAuth.includes('use_dpop_nonce')) {// 서버가 제공한 nonce 추출const nonceMatch = wwwAuth.match(/nonce="([^"]+)"/);if (nonceMatch) {currentNonce = nonceMatch[1];// 동일 요청을 nonce 포함하여 재시도const method = config.method?.toUpperCase() ?? 'GET';const url = new URL(config.url ?? '', config.baseURL);const htu = `${url.origin}${url.pathname}`;const newProof = await ExpoCryptoDpop.createProof({htm: method,htu,accessToken: getStoredAccessToken() ?? undefined,nonce: currentNonce,});config.headers['DPoP'] = newProof;return apiClient(config);}}}return Promise.reject(error);});
토큰 갱신(Token Refresh) 흐름
액세스 토큰 만료 시 DPoP를 사용하여 토큰을 갱신하는 완전한 흐름입니다.
import axios, { AxiosError } from 'axios';import ExpoCryptoDpop from 'expo-crypto-dpop';let isRefreshing = false;let refreshQueue: Array<(token: string) => void> = [];async function refreshAccessToken(): Promise<string> {const refreshToken = getStoredRefreshToken();// 토큰 갱신 요청에도 DPoP 증명 필요const proof = await ExpoCryptoDpop.createProof({htm: 'POST',htu: 'https://auth.example.com/oauth/token',});const response = await axios.post('https://auth.example.com/oauth/token',{grant_type: 'refresh_token',refresh_token: refreshToken,},{headers: { DPoP: proof },});const newAccessToken = response.data.access_token;storeAccessToken(newAccessToken);return newAccessToken;}// 토큰 만료 처리 인터셉터apiClient.interceptors.response.use((response) => response,async (error: AxiosError) => {const originalRequest = error.config as any;if (error.response?.status === 401 && !originalRequest._retry) {originalRequest._retry = true;if (isRefreshing) {// 이미 갱신 중이면 큐에 대기return new Promise((resolve) => {refreshQueue.push((token) => {originalRequest.headers['Authorization'] = `DPoP ${token}`;resolve(apiClient(originalRequest));});});}isRefreshing = true;try {const newToken = await refreshAccessToken();// 대기 중인 요청 모두 처리refreshQueue.forEach((cb) => cb(newToken));refreshQueue = [];originalRequest.headers['Authorization'] = `DPoP ${newToken}`;return apiClient(originalRequest);} finally {isRefreshing = false;}}return Promise.reject(error);});
완전한 API 클라이언트 예시
실제 프로젝트에서 사용할 수 있는 완성된 DPoP API 클라이언트입니다.
앱 시작 시 키 초기화
// app/_layout.tsx (Expo Router) 또는 App.tsximport { useEffect } from 'react';import ExpoCryptoDpop from 'expo-crypto-dpop';export default function RootLayout() {useEffect(() => {// 앱 시작 시 키 쌍 확보 (재설치 감지 포함)ExpoCryptoDpop.ensureKeyPair().catch(console.error);}, []);// ...}
API 클라이언트 설정
// lib/apiClient.tsimport axios from 'axios';import ExpoCryptoDpop from 'expo-crypto-dpop';import { tokenStorage } from './tokenStorage';const BASE_URL = 'https://api.example.com';export const apiClient = axios.create({ baseURL: BASE_URL });// ── 요청 인터셉터 ──────────────────────────────apiClient.interceptors.request.use(async (config) => {const method = (config.method ?? 'get').toUpperCase();const fullUrl = new URL(config.url ?? '', BASE_URL);const htu = `${fullUrl.origin}${fullUrl.pathname}`;const accessToken = tokenStorage.getAccessToken();const dpopProof = await ExpoCryptoDpop.createProof({htm: method,htu,accessToken: accessToken ?? undefined,nonce: tokenStorage.getDpopNonce() ?? undefined,});config.headers.DPoP = dpopProof;if (accessToken) {config.headers.Authorization = `DPoP ${accessToken}`;}return config;});// ── 응답 인터셉터 ──────────────────────────────apiClient.interceptors.response.use((res) => res,async (error) => {const { config, response } = error;if (!response || config._retried) return Promise.reject(error);const status = response.status;const wwwAuth: string = response.headers['www-authenticate'] ?? '';// nonce 갱신if (status === 401 && wwwAuth.includes('use_dpop_nonce')) {const match = wwwAuth.match(/nonce="([^"]+)"/);if (match) {tokenStorage.setDpopNonce(match[1]);config._retried = true;return apiClient(config);}}// 토큰 갱신if (status === 401) {try {await refreshAccessToken();config._retried = true;return apiClient(config);} catch {tokenStorage.clear();// 로그인 화면으로 이동}}return Promise.reject(error);});
사용 예시
// 일반 GET 요청 — DPoP 헤더 자동 추가const user = await apiClient.get('/user/profile');// POST 요청const result = await apiClient.post('/resource', { data: 'value' });
로그아웃 처리
로그아웃 시 DPoP 키를 삭제하면 보안이 강화됩니다. 다음 로그인 시 새 키가 생성됩니다.
async function logout() {// 1. 서버 측 토큰 폐기 (DPoP 증명 포함)await apiClient.post('/auth/logout');// 2. 로컬 토큰 삭제tokenStorage.clear();// 3. DPoP 키 쌍 삭제 (선택 사항 — 보안 강화)await ExpoCryptoDpop.deleteKeyPair();}
키 삭제는 선택 사항입니다. 키를 유지하면 다음 로그인 시 키 생성 없이 즉시 DPoP 증명을 만들 수 있습니다. 보안 정책에 따라 결정하세요.