redirect_uri 소유 검증 — 내부 설계 레퍼런스
Internal. 공개 문서(
oauth/redirect-uri-verification.md)의 상위 집합 — 엔드포인트 계약,Result#reason어휘집, TTL/freshness 정책, domain-autopromote 플로우를 한곳에 모은 운영·개발자용 참조. Wave 5b F5 + domain-autoapprove Phase 3.
1. 개요
logi 는 등록된 OAuth redirect_uri 의 호스트 소유권을 검증해 그 결과를 OauthApplication#redirect_uri_metadata(per-uri jsonb)에 stamp 합니다. 단일 진실은 Security::RedirectUriVerificationStatus.derive(tier:, entry:) — 콘솔 카드 / admin iOS 앱 / CLI 가 모두 이 status enum 을 봅니다.
- 검증기:
Security::RedirectUriVerifier(server/app/services/security/redirect_uri_verifier.rb) - stamp 래퍼:
Security::RedirectUriVerificationService.verify_and_stamp!(.../redirect_uri_verification_service.rb) - status 유도:
Security::RedirectUriVerificationStatus(.../redirect_uri_verification_status.rb) - 분류:
Oauth::RedirectUriClassifier(tier 판정)
2. 검증 프로토콜
두 갈래 챌린지. DNS TXT 우선(저렴/캐시), 실패 시 well-known HTTPS fallback.
- DNS TXT —
_logi-verify.<host>TXT 레코드가 챌린지 값과 일치. - well-known —
https://<host>/.well-known/logi-verification.txtbody 가 챌린지 값과 일치.
챌린지 유도: HMAC-SHA256(key, "{app.id}:{uri}") 의 hex. key = "<secret>:logi-redirect-verify". 결정적이라 DB 컬럼 추가 없이 안정.
키 링 회전 (Phase 2, T-7): verification_keys 는 newest-first 목록. 활성 키(첫 항목)로 챌린지를 생성(challenge_for)하고, 검증은 링의 모든 키를 수용(acceptable_challenges). LOGI_REDIRECT_VERIFY_KEYS(comma-sep) 미설정 시 [secret_key_base] — 기존 챌린지와 byte-identical. secret_key_base 회전 시 이전 키를 유예기간 동안 링에 남겨 전 RP 동시 재검증 실패(대량 revoke)를 방지. 활성 챌린지 값이 불변이므로 POLICY_VERSION 은 bump 하지 않는다(bump 시 저장된 autopromote 증거가 게이트에서 일괄 무효화됨).
SSRF 방어
- pre-gate: localhost /
.localhost/ RFC1918·RFC4193 / IP 리터럴 / metadata IP 는 네트워크 호출 전에 차단(:unverifiable_host). - pinned-IP connect:
WebhookUrlValidator.resolve_safe!로 1회 resolve → 그 IP 로 직접 connect, SNI 는 원 호스트 제시, cert CN/SAN 을 원 호스트와 검증(post_connection_check) → DNS rebinding 봉쇄. - redirect 거부: 3xx 즉시 fail(
:redirect_not_allowed). - body 캡: 256바이트 초과 즉시 abort(
:body_too_large). - 단일 통합 deadline (Phase 2, T-3): connect + TLS + status line + 모든 header line + body 가 하나의 monotonic deadline(
OVERALL_HTTP_DEADLINE = 6s)을 공유. per-read 타임아웃을 개별 리셋하던 slow-loris 헤더 플러드 창을 봉쇄.
3. 엔드포인트 계약
검증 트리거는 세션 + OTP step-up 이 필요한 민감 액션이라 콘솔/admin 에만 존재. 관찰은 PAK 로도 가능.
developer (콘솔, 세션 + OTP)
POST /developer/applications/:id/verify_redirect_uri— body{ uri }. 게이트: 앱 가시성(scoped_applications) →authorize_app_management_json!(403) →enforce_recent_otp_for_sensitive_json(401step_up_required). 비동기(Phase 2):Oauth::RedirectUriVerifyJobenqueue 후202 { uri, status: "queued" }. rate limit:(app,uri)60초 1회(429 rate_limited) + 개발자/일 50회(429 daily_limit_reached).GET /developer/applications/:id/redirect_uri_verifications— per-uri{ uri, tier, challenge_dns_record, challenge_wellknown_url, challenge_wellknown_body, verified_at, verification_method, expires_at, status, last_attempt }.
admin (/api/v1/admin, AdminJwt + step-up)
POST .../applications/:id/verify_redirect_uri— developer 와 동일 비동기 계약(202 queued). 60초 rate limit 유지, 일일 상한 미적용(특권·저볼륨, 전역 큐 concurrency 로 여전히 bounded).GET .../applications/:id/redirect_uri_verifications— dev 와 byte-identical shape.
PAK (/api/v1/applications/:id, CLI 관찰용)
GET /api/v1/applications/:idshow 페이로드에redirect_uri_verifications요약 포함: per-uri{ uri, tier, status, verified_at, verification_method, expires_at, last_attempt }. challenge 값 없음 — 트리거는 콘솔 전용이므로 PAK 페이로드엔 증거 material 을 싣지 않는다. CLI(logi apps verify <id> [-r <uri>] [--poll])가 이 요약을 읽어 상태 표시 +queued→done폴링.
비동기 결과 관찰 (Phase 2/3)
- 성공은
verified_at(영속) 로 관측. - 대기/실패는 transient
last_attempt(Oauth::RedirectUriVerifyJob이Rails.cache에RESULT_TTL=10m로 기록):{ "state" => "queued" }{ "state" => "done", "verified" => bool, "method" => "dns"|"wellknown"|null, "reason" => <reason>|null, "detail" => str|null }nil— TTL 창 내 시도 없음.
- 전역 프로브 동시성: 전용 큐
redirect_uri_verification(queue.yml threads:2) + joblimits_concurrency to: 2이중 방어.
4. Result#reason 어휘집
Security::RedirectUriVerifier::Result 의 reason(검증 실패 시). method 는 성공 시 :dns/:wellknown.
| reason | 의미 |
|---|---|
:unparseable_uri | URI 파싱 실패 |
:unverifiable_host | blank host / localhost / IP 리터럴 / 사설망 — pre-gate 차단 |
:unverified | DNS·well-known 둘 다 챌린지 불일치 |
:dns_no_record | _logi-verify.<host> TXT 레코드 없음 |
:dns_timeout | DNS 조회 타임아웃 |
:dns_error | 기타 DNS 오류 |
:ssrf_blocked | WebhookUrlValidator.resolve_safe! 차단 |
:not_found | well-known 404 / 기타 4xx |
:server_error | well-known 5xx |
:redirect_not_allowed | well-known 3xx |
:tls_invalid | TLS 인증서/핸드셰이크 검증 실패 |
:timeout | 통합 deadline(6s) 초과 |
:body_too_large | well-known body 256B 초과 |
:http_error | 기타 HTTP/소켓 오류 |
status enum (RedirectUriVerificationStatus.derive, 콘솔/CLI 표시용): verified · expired · unverifiable_host · unverified.
5. TTL / freshness 정책
- verified URI 는 무기한:
verify_and_stamp!성공 시verified_atstamp +expires_atclear. 만료 리마인더 job 은verified_at있는 엔트리를 skip. - 미검증 tier TTL:
ephemeral/production_unverified/staging_unverified는expires_at을 갖고,Oauth::RedirectUriExpiryReminderJob(15분)이 T-7d/T-3d/T-24h/T-1h 리마인더 + 만료 시expired_atsoft-mark. - autopromote freshness (Phase 1/2): 프로덕션 자동승격에 쓰인 DNS 증거는
PRODUCTION_VERIFICATION_FRESHNESS_WINDOW = 35일을 넘으면 게이트가 fail-closed 거부.Oauth::RedirectUriReverificationJob(월간)이 DNS 를 재확인해 통과 시verified_at갱신, 실패 시verified_atclear(per-URI) + 앱 revoke. 35일 = 월간(≈30일) 주기 + 5일 슬랙(결합 상수 — 주기 변경 시 함께 조정).
6. domain-autopromote 플로우
RP 가 자기 redirect_uri 의 도메인 소유를 DNS TXT 로 증명하면, 그 URI 에 한해 live authorize 를 허용(Option A per-URI 바이패스)하고 앱 status: pending → approved 를 원자적으로 부여. 플래그 LOGI_DOMAIN_AUTOPROMOTE(기본 false)로 게이트. LOGI_BETA_AUTO_APPROVE(무검증 blanket)와는 완전 별개.
- 게이트 (
OauthApplication#redirect_uri_verified_for_production?): entry 존재 →requires_risk_acknowledgement?false(T-9) → 런타임 재분류가production_unverified/staging_unverified(T-6/T-8) → https canonical(fragment/userinfo 금지, T-1) → 저장된classifier_version/verification_policy_version가 현재 상수와 일치(T-6) →verification_method == "dns"(well-known 배제, T-10) →verified_atfreshness ≤ 35d(T-4) → config 미드리프트(T-2). 전부 통과해야 true. - 원자적 전이 (
autopromote_status_via_domain_verification!):with_lock+reload,status == "pending"에서만(suspended/approved 부활 금지, T-5), 락 안 게이트 재평가(TOCTOU).approve_by!미재사용. - config drift revoke (T-2): 승격 시점 config digest 동결. scope/name/logo/app_link/redirect_uris 변경 시 런타임 fingerprint 불일치 → 게이트 즉시 false +
after_commit(및 scope-only 변경은set_scopes!)이 revoke 기록 + 개발자 메일. - fail-closed 재검증 (T-4): §5 의 월간 job. DNS 재확인만 인정, 실패 시 per-URI
verified_atclear + 앱 revoke. 프로브→락 TOCTOU 는 프로브 시점verified_at(µs 정밀도 생성 토큰) 캡처 후 락 안에서 불변일 때만 실패 적용.
상세 체크리스트/위협모델: .planning/domain-autoapprove-fast-follow.md.
7. 관련 소스
server/app/services/security/redirect_uri_verifier.rb— 검증기(DNS/well-known/SSRF/키 링/단일 deadline)server/app/services/security/redirect_uri_verification_service.rb— stamp 래퍼server/app/services/security/redirect_uri_verification_status.rb— status enumserver/app/jobs/oauth/redirect_uri_verify_job.rb— 비동기 verify + last_attemptserver/app/jobs/oauth/redirect_uri_reverification_job.rb— 월간 fail-closed 재검증server/app/models/oauth_application.rb— autopromote 게이트/전이/revokeserver/app/controllers/{developer,api/v1/admin,api/v1}/applications_controller.rb— 엔드포인트cli/lib/logi/commands/app.rb—logi apps verify(관찰/폴링)