Skip to content

사용자 신원 재확인 (Verification)

결제 직전, 계정 정보 변경 직전처럼 로그인과 무관한 시점에 "지금 이 사람이 본인이 맞는지, 이 작업을 승인하는지"를 사용자 폰에 묻는 API다. 사용자는 1pass 앱에서 내용을 확인하고 승인하거나 거절하며, RP 는 그 결정을 서명된 영수증(JWS) 으로 받는다.

로그인 세션을 새로 만들지 않는다. 산출물은 토큰이 아니라 결정이다.

능력 플래그가 필요하다

개발자 콘솔에서 앱별로 켜야 하고, 켠 뒤에도 어떤 purpose 를 쓸지 따로 선택해야 한다. 아무것도 선택하지 않은 상태는 "전부 금지"다.

현재 구현 상태 — 사용자 결정 화면은 준비 중

서버 브로커(생성·폴링·소비·취소·영수증)는 동작하지만, 사용자가 승인/거절할 화면이 아직 없다. 생성 응답의 verification_url(/verify/:id)에 대응하는 라우트와 1pass 앱의 확인 시트가 미구현이라, 지금 요청을 만들면 사용자는 결정할 수단이 없어 그대로 만료된다.

지금은 스테이징에서 계약을 맞춰보는 용도로만 쓴다. 실사용 통합은 이 안내가 사라진 뒤에 시작하는 것이 맞다.

언제 쓰나

쓴다쓰지 않는다
결제 승인, 송금 확인로그인 자체 → OAuth 로그인
이메일·비밀번호·2FA 변경 확인세션 재인증만 필요 → prompt=login
데이터 내보내기 승인AI 에이전트의 도구 실행 게이팅 → Agent Approval
민감 작업 전 본인 확인(step-up)

준비

  1. 콘솔에서 켠다 — 개발자 콘솔 → 앱 → "신원 재확인 요청" 체크
  2. purpose 를 고른다 — 필요한 것만. high_risk_action 은 자유 문구 지면이라 명시적으로 골라야 열린다
  3. confidential client 여야 한다client_id 는 앱 바이너리에 담기는 공개값이라, public client 를 허용하면 그 값을 아는 누구나 임의 사용자에게 승인 프롬프트를 쏠 수 있다. 이 API 는 HTTP Basic 인증만 받는다

전체 흐름

RP 서버                        logi                          사용자 폰
   |                            |                                |
   |-- POST /rp/v1/verifications ->                              |
   |<- auth_req_id, number_match |-- 푸시 ---------------------->|
   |                            |                                |
   | (number_match 를 내 화면에 표시)          (사용자가 내용 확인 →
   |                            |            6자리 입력 → FaceID)|
   |-- GET  .../{id} (폴링) ---->|<-------------------------------|
   |<- status: approved, receipt |                                |
   |                            |                                |
   |-- POST .../{id}/consume --->|  (1승인 = 1실행 바인딩)
   |<- status: consumed, receipt |
   |                            |
   | (영수증 검증 후 실제 작업 실행)

폴링이 기본이다. 웹훅도 동시에 지원한다(verification.requested 이벤트) — 자세한 것은 Webhook 연동.

1. 요청 생성

http
POST /rp/v1/verifications
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/json
json
{
  "user_sub": "사용자의 pairwise sub",
  "purpose": "payment",
  "idempotency_key": "order-8821-confirm",
  "nonce": "3f9a2b7c8d1e4f60a5b3c2d1e0f9a8b7",
  "binding_digest": "실행할 작업의 SHA-256 hex(64)",
  "context": { "amount": 12000, "currency": "KRW", "merchant": "OO상점" },
  "requested_expiry": 180,
  "number_match_required": true
}
필드필수설명
user_sub로그인 때 받은 그 사용자의 sub. 앱마다 값이 다르다(pairwise)
purpose아래 5종 중 하나. 콘솔에서 허용한 것만
idempotency_key같은 키 + 같은 페이로드 재요청은 기존 요청을 그대로 돌려준다
nonce영수증이 이 요청의 것임을 묶는다. 16자 이상, 매번 새로 만든다
binding_digest승인받은 작업과 실제 실행할 작업을 묶는 해시. consume 때 다시 낸다
contextpurpose 별승인 화면에 보여줄 내용. payment·account_change 는 필수
requested_expiry초. 60~600 사이로 잘린다. 기본 180
number_match_required6자리 확인번호를 요구한다. payment·account_change 는 지정 여부와 무관하게 항상 켜진다

응답:

json
{
  "auth_req_id": "vrq_...",
  "status": "pending",
  "expires_in": 180,
  "interval": 2,
  "number_match": "042917",
  "delivery_targets": 2,
  "verification_url": "https://api.1pass.dev/verify/vrq_...",
  "reused": false
}
  • number_match내 화면에 표시하는 값이다. 사용자가 폰에서 이 6자리를 직접 입력해야 승인이 성립한다. 푸시 알림에는 절대 담기지 않는다
  • delivery_targets0 이면 사용자에게 푸시를 보낼 기기가 없다. 즉시 다른 확인 수단으로 넘어가야 한다
  • reused: true 는 같은 (사용자, 앱, purpose) 로 이미 떠 있는 요청을 돌려줬다는 뜻이다

purpose 와 context 슬롯

승인 화면의 문장을 조립하는 것은 logi 다. RP 는 타입이 정해진 값만 채운다. 자유 문구를 허용하면 그 자리가 "logi 보안팀입니다" 같은 사칭 지면이 되기 때문이다.

purposecontext 슬롯확인번호
paymentamount 정수 필수 · currency [A-Z]{3} 필수 · merchant ≤40자항상
account_changefield (email|phone|password|2fa) 필수 · masked_new_value ≤40자항상
data_exportdata_kind ≤40자 필수 · destination ≤40자선택
high_risk_actionaction_label ≤40자 필수선택
login_step_up없음선택

필수 슬롯이 하나라도 있는 purpose 는 context 자체를 생략할 수 없다 — 위 네 purpose(payment·account_change·data_export·high_risk_action)가 여기 해당한다. 빈 객체({})도 생략과 같게 본다. "확인번호 선택"과는 별개 축이다.

amount 는 최소 화폐 단위 정수

ISO 4217 exponent 기준이다 — KRW·JPY 는 원·엔 그대로, USD 는 센트. 소수(12000.0)나 서식 문자열("12,000")은 거부한다. 같은 금액이 두 표현으로 들어오면 승인 화면 해시가 갈리고, 서식은 폰의 로케일 책임이기 때문이다.

context 를 생략할 수 있는 것은 필수 슬롯이 없는 login_step_up 뿐이고, 그때 폰은 앱 이름과 purpose 라벨만 보여준다. 나머지 네 purpose 는 생략·빈 객체 모두 422 다 — 금액도 대상도 없는 결제 승인 화면은 blind approve 이고, 같은 이유가 내보낼 데이터 종류(data_kind)와 행동 이름(action_label)에도 적용된다.

binding_message모든 purpose 에서 422 다. 위 슬롯을 쓴다.

2. 폴링

http
GET /rp/v1/verifications/{auth_req_id}
Authorization: Basic ...
json
{ "auth_req_id": "vrq_...", "status": "approved", "receipt": "eyJ..." }

interval(초) 간격으로 조회한다. statuspending · approved · denied · expired · consumed 다.

결정이 난 요청에는 receipt 가 함께 온다. 아직 결정 전이거나 결정 후 300초가 지나면 키 자체가 빠진다.

3. 소비 (1승인 = 1실행)

승인을 실제 실행 권한으로 바꾸는 지점이다. 반드시 거친다.

http
POST /rp/v1/verifications/{auth_req_id}/consume
Authorization: Basic ...

{ "binding_digest": "실행할 작업의 SHA-256 hex(64)" }
json
{ "auth_req_id": "vrq_...", "status": "consumed", "receipt": "eyJ..." }
  • 생성 때 낸 binding_digest 와 다르면 409 binding_mismatch — 사용자가 본 것과 다른 작업이 실행되는 것을 막는다
  • 두 번째 호출은 409 not_consumable — 한 승인은 한 번만 쓴다
  • 승인 후 300초가 지나면 소비할 수 없다
  • 사용자가 그 사이 앱 연결을 끊었으면 403 app_not_connected

status: "approved" 만 보고 실행하면 이 보호가 전부 무효가 된다.

4. 취소

http
POST /rp/v1/verifications/{auth_req_id}/cancel

RP 가 자기 요청을 접는다(사용자 화면에서 사라진다). pending 만 대상이고, 이미 결정된 요청은 409 not_cancellable.

영수증 검증

영수증은 RS256 JWS 다. logi 를 신뢰하지 않고 검증할 수 있다는 점이 존재 이유이므로, 폴링 응답의 status 를 믿는 대신 이것을 검증한다. 서명키는 로그인에 쓰는 그 JWKS 와 같다 — 새로 배포할 키가 없다.

Node — SDK 로

bash
npm i @logi-auth/server
js
import { verifyVerificationReceipt } from "@logi-auth/server";

// jwks 는 https://api.1pass.dev/.well-known/jwks.json 에서 받아 캐시해 둔다
const decision = await verifyVerificationReceipt(receipt, {
  jwks,
  expected: {
    issuer:   "https://api.1pass.dev",
    clientId: process.env.LOGI_CLIENT_ID,
    nonce,          // 요청 생성 때 보낸 그 값
    bindingDigest,  // 지금 실행하려는 작업
  },
});
// decision.sub / .authReqId / .purpose / .decidedAt — 이제 실행해도 되는 상태다

아래 축을 전부 확인하고, 실패하면 어떤 축이 틀렸는지 code 가 붙은 ReceiptError 를 던진다. 거절 영수증을 검증하려면 expected.decision: "denied" 를 넘긴다(기본값은 "approved").

직접 검증할 때

js
import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(new URL("https://api.1pass.dev/.well-known/jwks.json"));

async function verifyReceipt(receipt, { clientId, nonce, bindingDigest }) {
  const { payload, protectedHeader } = await jwtVerify(receipt, JWKS, {
    algorithms: ["RS256"],
    issuer: "https://api.1pass.dev",
    audience: clientId,
    clockTolerance: 60,
  });

  // 같은 키로 서명된 id_token 을 영수증 자리에 끼워넣는 swap 을 헤더에서 끊는다
  if (protectedHeader.typ !== "verification_receipt+jwt") throw new Error("not a receipt");

  // 이 영수증이 방금 내가 만든 그 요청의 것인지
  if (payload.nonce !== nonce) throw new Error("nonce mismatch");
  if (payload.decision !== "approved") throw new Error("not approved");

  // 지금 실행하려는 작업이 승인받은 그 작업인지
  if (bindingDigest && payload.binding_digest !== bindingDigest) {
    throw new Error("binding mismatch");
  }

  return payload;
}

네 가지를 모두 봐야 한다

서명만 검증하면 다른 RP 의 영수증, 다른 요청의 영수증, 오래된 영수증, 다른 작업의 영수증이 전부 통과한다. aud · nonce · exp · binding_digest 는 서명과 함께 봐야 의미가 있다.

클레임

클레임
iss / audlogi issuer / 내 client_id
sub사용자의 pairwise sub (로그인 때 받은 값과 같다)
jtiauth_req_id — 요청당 하나
iat / exp결정 시각 / 결정 시각 + 300초
nonce생성 때 보낸 값
purpose / decision / decided_at / auth_timepurpose, approved|denied, 결정 시각
display_digest사용자가 실제로 본 화면 전체의 해시. RP 는 이 값을 다른 경로로 받지 않으므로 대조 대상이 아니라 감사 기록이다
binding_digest승인된 작업 바인딩 (요청에 없었으면 생략)
amr / acr패스키 사용자 확인(UV)을 거쳤을 때만. 증거가 없으면 생략한다

영수증은 결정론적이다. 같은 결정을 여러 번 조회해도 바이트가 같다. 첫 응답을 놓치고 다시 폴링해도 같은 토큰이 오므로, jti 로 재생을 막는 구현이 정상적인 재시도까지 거절하지 않는다.

거절(denied)에도 영수증이 나온다. "사용자가 명시적으로 거절했다"를 기록에 남길 수 있어야 하기 때문이다.

검증기를 그대로 돌려볼 수 있다

저장소의 server/script/verify_verification_receipt.rb 는 logi 서버 코드를 한 줄도 쓰지 않고 JWKS 만으로 영수증을 검증하는 독립 스크립트다. 위 네 축을 모두 확인하며, 통합 전에 실제 영수증을 넣어 동작을 확인하는 용도로 쓸 수 있다.

에러

상태코드
401invalid_clientBasic 인증 실패, 또는 public client
403verification_not_enabled콘솔에서 이 기능이 꺼져 있다
403purpose_not_enabled기능은 켜졌지만 이 purpose 는 허용되지 않았다
403app_not_connected사용자가 이 앱과 연결돼 있지 않다(또는 연결을 끊었다)
403blocked_by_user사용자가 이 앱의 확인 요청을 차단했다
422invalid_request필드 누락·형식 오류·슬롯 스키마 위반·binding_message 사용
422unknown_purposepurpose 가 정의된 5종 밖이다
422user_unresolveduser_sub 로 사용자를 찾을 수 없다
409idempotency_conflict같은 키를 다른 내용으로 재사용했다
409in_flight_conflict같은 사용자·앱·purpose 로 다른 내용의 요청이 이미 떠 있다
409binding_mismatch소비하려는 작업이 승인받은 작업과 다르다
409not_consumable미승인·이미 소비됨·소비 창 만료
409not_cancellable이미 결정된 요청은 취소할 수 없다
429rate_limited한도 초과 (Retry-After 참조)
429cool_down_active거절·무응답 뒤 쿨다운 중 (Retry-After 참조)

purpose_not_enabled나머지 페이로드 검증보다 먼저 판정한다. 갖고 있지 않은 능력에 대해서는 검증 동작조차 노출하지 않는다.

한도와 프롬프트 피로 방어

승인 프롬프트를 무한정 쏠 수 있으면 사용자는 결국 습관적으로 승인하게 된다. 네 겹으로 막는다.

방어내용
in-flight 억제같은 (사용자, 앱, purpose) 로 떠 있는 요청은 1개. 같은 내용이면 기존 것을 돌려주고, 다르면 409
거절 쿨다운1시간 내 첫 거절 60초, 두 번째부터 10분
무응답 쿨다운연속 3회 무응답이면 15분
레이트리밋앱당 분당 60건, (사용자, 앱) 당 시간당 10건

사용자는 1pass 앱에서 특정 RP 의 확인 요청을 아예 차단할 수 있다. 차단되면 403 blocked_by_user 이고, 이 상태는 RP 가 해제할 수 없다.

알아둘 것

  • 잠금화면에는 앱 이름과 purpose 라벨만 뜬다. 금액도, 확인번호도, RP 가 보낸 어떤 문자열도 담기지 않는다. 잠금화면 문자열의 저자는 항상 logi 다
  • 사용자를 찾지 못한 요청도 한도에 걸린다. user_sub 를 바꿔가며 실재 여부를 캐내는 열거를 막기 위해 별도 스로틀이 붙어 있다
  • 영수증의 exp 는 300초이고 소비 창과 같다. 승인을 받아두고 나중에 쓰는 설계는 성립하지 않는다
  • 기기 서명 영수증은 아직 없다. 현재 영수증의 서명자는 logi 서버다. 기기(패스키) 서명은 credential 공개키가 RP 에 노출되는 문제와 WebAuthn challenge 유도 변경이 선행돼야 해서 후속 과제다

함께 보기

Identity가 제품의 신뢰를 만듭니다.