툴과 개발 환경8 분 읽기

토큰은 이미 있었습니다 — 그 문의 열쇠가 아니었을 뿐입니다

같은 회사, 같은 .p8 키, 같은 팀 ID. 그런데 한쪽은 Bearer 토큰을 받고 다른 쪽은 URL 서명을 받습니다. 401을 보고 저는 계속 키를 의심했습니다.

#api#gotchas#reality-check#verification#maps
왼쪽: MapKit JS 는 Authorization Bearer JWT 로 200. 오른쪽: 같은 키로 만든 같은 JWT 를 스냅샷 API 에 보내면 401, teamId·keyId 를 쿼리에 넣고 path+query 를 ES256 으로 서명해 signature 파라미터로 붙여야 200 image/png.
같은 열쇠공이 만든 두 개의 문. 열쇠는 하나가 아니었습니다.

GPS 영토 걷기 앱에 공유 기능을 붙이고 있었습니다. 사용자가 걸어서 딴 영역을 짧은 세로 영상으로 만들어 내보내는 기능입니다. 영상 배경에는 그 사람이 실제로 걸은 동네 지도가 한 장 깔려야 합니다.

지도 이미지는 Apple 의 Maps Web Snapshots 로 받기로 했습니다. URL 하나에 좌표와 크기를 넣으면 PNG 를 돌려주는 API 입니다. 서버에서 한 번 받아 캔버스에 깔면 끝입니다.

그리고 저에게는 이미 토큰 발급 경로가 있었습니다. 같은 프로젝트가 웹에서 MapKit JS 지도를 띄우고 있었으니까요. /api/mapkit-token 이 몇 달째 잘 돌고 있었습니다.

그래서 그 토큰을 그대로 Authorization: Bearer 에 실어 보냈습니다.

401

제가 의심한 것들

여기서 무엇을 의심하시겠습니까?

저는 전부 토큰 쪽 을 봤습니다. 만료됐나. 유효기간이 너무 짧나. origin 제한이 걸렸나. 이 키에 스냅샷 권한이 따로 필요한가. 발급 파라미터를 바꿔가며 다시 만들고, 다시 보내고, 다시 401 을 받았습니다.

가설이 네 개였지만 전제는 하나였습니다 — "신분증이 잘못됐다". 401 이 그렇게 읽히니까요. 401 은 "당신이 누군지 모르겠다" 는 뜻이고, 그러면 자연스럽게 신분증을 고치게 됩니다.

같은 키로 만든 토큰으로 웹 지도는 그 순간에도 정상이었습니다. 그 사실이 힌트였는데, 저는 그걸 "키는 멀쩡하니 발급 옵션이 문제겠지" 로 읽었습니다. 실제로는 문이 두 개 라는 뜻이었습니다.

실체

Maps Web Snapshots 는 Bearer 토큰을 아예 받지 않습니다. 인증 체계가 다릅니다.

  • 쿼리 문자열에 teamIdkeyId 를 넣습니다.
  • "<경로>?<쿼리>" 문자열 전체를 그 .p8 개인키로 ES256 서명합니다.
  • 서명을 base64url 로 인코딩해 &signature=URL 끝에 붙입니다.

헤더는 없습니다. 요청 URL 자체가 신분증입니다.

export function signedSnapshotUrl(params, { teamId, keyId, privateKeyPem }) {
  const q = new URLSearchParams(params);
  q.set("teamId", teamId);
  q.set("keyId", keyId);
 
  // 서명은 Apple 이 받게 될 요청 라인을 그대로 덮습니다.
  // 쿼리 문자열은 한 번만 직렬화하고 그걸 그대로 재사용해야 합니다.
  const pathAndQuery = `${SNAPSHOT_PATH}?${q.toString()}`;
  const signer = crypto.createSign("SHA256");
  signer.update(pathAndQuery);
  signer.end();
  const signature = base64url(signer.sign(crypto.createPrivateKey(privateKeyPem)));
 
  return `${SNAPSHOT_HOST}${pathAndQuery}&signature=${signature}`;
}

200 image/png. 같은 키, 같은 팀, 같은 회사입니다. 다른 문이었을 뿐입니다.

여기서 한 번 더 헤맬 뻔한 것

ES256 서명에는 인코딩이 두 가지 있습니다. DERIEEE P1363 입니다. JWT 표준은 P1363 을 강제하는데, Node 의 createSign(...).sign(key) 기본 출력은 DER 입니다. 그래서 JWT 를 직접 만들어 본 사람은 보통 여기서 변환 코드를 한 번 씁니다.

그래서 둘 다 만들어 던져봤습니다. 둘 다 200 이었습니다.

스냅샷 API 는 두 인코딩을 모두 받습니다. 즉 Node 기본값 그대로 쓰면 되고, 변환 코드는 애초에 필요가 없었습니다. 예상되는 함정을 막는 코드를 먼저 쓰는 대신 일단 한 번 던져본 것 이 20줄을 아꼈습니다. 방어 코드도 재고입니다 — 안 쓰이는 재고는 나중에 읽는 사람을 헷갈리게 합니다.

그림을 받은 다음에 알게 된 두 가지

지도 타입 기본값이 영상과 안 어울립니다. t=standard 는 채도 높은 청록으로 나옵니다. 어두운 배경 위에 오버레이로 얹으면 그 부분만 튀어서 스티커처럼 보입니다. t=mutedStandard 로 바꾸니 배경으로 물러났습니다.

좌하단에 Apple Maps 표기가 이미지에 구워져 나옵니다. 지우거나 덮을 수 없고, 덮으면 안 됩니다. 그래서 영상 레이아웃을 짤 때 그 모서리는 처음부터 비워둬야 합니다. 자막이나 로고를 거기 놓고 나중에 발견하면 레이아웃을 다시 짜야 합니다.

자가진단 3가지

  1. "이미 그 회사 토큰이 있으니 될 것"이라고 가정한 API 가 있습니까? 같은 벤더의 API 두 개가 서로 다른 인증 체계를 쓰는 건 흔합니다. 문서의 인증 섹션만 30초 읽으면 갈립니다.
  2. 401 을 받았을 때, 자격증명 말고 다른 걸 의심해본 적이 있습니까? 401 은 "신분증이 틀렸다" 뿐 아니라 "신분증을 그런 식으로 내는 게 아니다" 일 수 있습니다.
  3. 예상되는 함정을 막는 코드를 쓰기 전에 한 번 실행해봤습니까? 저는 필요 없는 인코딩 변환기를 쓸 뻔했습니다. 문서에서 읽은 제약이 이 엔드포인트에도 적용되는지는 던져봐야 압니다.

솔직한 부분

이건 제 실수였고, 코드 문제가 아니라 읽기 순서 문제였습니다.

저는 문서보다 제 코드베이스를 먼저 봤습니다. /api/mapkit-token 이 있는 걸 보고 "인증은 이미 풀린 문제" 라고 판단했습니다. 그 판단이 그 다음 네 번의 시도 방향을 전부 정했습니다. 문서의 인증 섹션을 먼저 읽었으면 첫 시도에 됐을 일입니다.

같은 성질의 실패를 최근에 또 겪었습니다 — 테스트 200개가 통과했고, 버튼은 아무것도 하지 않았습니다. 그때도 "이 부분은 이미 검증됐다" 는 믿음이 검증 안 된 구간을 가렸습니다. 콘솔은 조용했고, 화면은 비어 있었습니다 도 결국 같은 이야기입니다: 증거가 제가 안 보던 곳에 있었습니다.

지금 당신 프로젝트에 "그건 이미 돌아가니까" 로 건너뛰고 있는 구간이 있습니까? 그게 정말 같은 문입니까?

관련 글