redirect_uri 자동 검증 (DNS / well-known)
logi 는 등록된 OAuth redirect_uri 의 호스트 소유권을 DNS TXT 또는 .well-known HTTPS 챌린지로 검증합니다. 검증 결과는 OauthApplication#redirect_uri_metadata 에 stamp 되고, RP 콘솔의 카드 / admin iOS 앱 / logi apps verify CLI 가 같은 endpoint 를 호출해 같은 status enum 을 본 (Security::RedirectUriVerificationStatus 가 단일 진실).
핵심
- 두 갈래 챌린지: DNS TXT 우선 (저렴 / 캐시 가능) → 실패 시 well-known HTTPS fallback.
- HMAC 챌린지 값:
HMAC-SHA256(secret_key_base + ":logi-redirect-verify", "{app.id}:{uri}")의 hex. 결정적이라 DB 컬럼 추가 없이도 안정. - SSRF 방어: localhost / RFC1918 / IP 리터럴 / metadata IP 는 네트워크 호출 전에 pre-gate 차단.
- rate limit:
(app, uri)당 60초 1회.Rails.cache.write(..., unless_exist: true)atomic. - manual CLI 와 짝:
logi apps verify <app_id> <uri>가 이 endpoint 의 thin wrapper. 사용자가 직접 호출할 일은 거의 없음.
검증 흐름
sequenceDiagram
autonumber
participant Console as RP 콘솔 / CLI / Admin iOS
participant API as logi 서버
participant DNS as DNS resolver
participant RP as RP 호스트 (HTTPS)
Console->>API: GET .../redirect_uri_verifications
API-->>Console: { verifications: [ {uri, challenge_dns_record, challenge_wellknown_*, status} ] }
Note over Console: RP 운영자가 DNS TXT 또는<br/>.well-known 파일 배포
Console->>API: POST .../verify_redirect_uri { uri }
API->>API: rate-limit check (60s per (app, uri))
API->>API: pre-gate (localhost / IP literal / RFC1918 차단)
API->>DNS: _logi-verify.<host> TXT?
alt DNS TXT match
DNS-->>API: TXT == challenge
API-->>Console: { verified: true, method: "dns" }
else DNS 실패
API->>RP: GET https://<host>/.well-known/logi-verification.txt
Note over API,RP: pinned-IP TLS + SNI 검증<br/>리디렉트 거부 · body 256B 캡
alt body match
RP-->>API: 200 + challenge
API-->>Console: { verified: true, method: "wellknown" }
else
RP-->>API: 404 / mismatch / timeout
API-->>Console: { verified: false, reason: "unverified", detail: "dns=... wellknown=..." }
end
endEndpoints
Developer 콘솔 (RP 소유자용)
| Method | Path | 인증 |
|---|---|---|
GET | /developer/applications/:id/redirect_uri_verifications | 세션 쿠키 (RP 소유자 / org member) |
POST | /developer/applications/:id/verify_redirect_uri | 세션 쿠키 + manage 권한 + 최근 5분 내 OTP 재인증 |
라우트 정의 — config/routes.rb:524-525:
get :redirect_uri_verifications
post :verify_redirect_uri- Authz (
developer/applications_controller.rb):set_application→scoped_applications.find→ 보이지 않으면 404 (다른 org / 멤버십 없음).- GET 은 visibility-only — manage 체크 없음 (관리자도 동일).
- POST 는 추가로
authorize_app_management_json!→ 403forbidden, 그리고enforce_recent_otp_for_sensitive_json→ 401step_up_required(또는otp_not_enrolled).
- 감사: 결과는
Authentication::AuditLogger.record!(event_type: "redirect_uri_verify_dev", ...)로 기록. event_type 은AuthenticationAuditLog::EVENT_TYPES화이트리스트 등록 필수.
Admin iOS 앱용 (/api/v1/admin)
| Method | Path | 인증 |
|---|---|---|
GET | /api/v1/admin/applications/:id/redirect_uri_verifications | admin session token (no step-up) |
POST | /api/v1/admin/applications/:id/verify_redirect_uri | admin session + step-up |
라우트 정의 — config/routes.rb:860-861:
get :redirect_uri_verifications
post :verify_redirect_uri응답 schema 와 핵심 동작은 developer 쪽과 byte-for-byte 동일 (build_verification_entry 가 양쪽에서 동일 구현, Security::RedirectUriVerificationStatus 가 단일 status enum). 차이는 인증 / 감사뿐:
- 감사 채널:
AdminAuditLog.create!(action: "admin.application.redirect_uri.verify", ...).
GET 응답
GET /developer/applications/42/redirect_uri_verifications{
"application_id": 42,
"verifications": [
{
"uri": "https://app.example.com/auth/callback",
"tier": "https_public",
"challenge_dns_record": "_logi-verify.app.example.com TXT \"f3a1...e9\"",
"challenge_wellknown_url": "https://app.example.com/.well-known/logi-verification.txt",
"challenge_wellknown_body": "f3a1...e9",
"verified_at": "2026-05-25T12:34:56Z",
"verification_method": "dns",
"expires_at": "2026-08-25T12:34:56Z",
"status": "verified"
},
{
"uri": "logi-app://oauth/callback",
"tier": "custom_scheme",
"challenge_dns_record": null,
"challenge_wellknown_url": null,
"challenge_wellknown_body": "ab12...77",
"verified_at": null,
"verification_method": null,
"expires_at": null,
"status": "unverifiable_host"
}
]
}| 필드 | 설명 |
|---|---|
uri | 등록된 redirect_uri 원본. |
tier | Oauth::RedirectUriClassifier 분류 — https_public / https_org / localhost / custom_scheme / unknown. |
challenge_dns_record | RP 운영자가 publish 할 TXT 레코드 문자열. 호스트가 비어 있거나 IP 리터럴이면 null. |
challenge_wellknown_url | well-known 파일을 둘 절대 URL. |
challenge_wellknown_body | well-known 파일에 그대로 적어야 할 챌린지 값 (HMAC hex). |
verified_at | 마지막으로 성공 stamp 된 시각 (ISO 8601). 미검증이면 null. |
verification_method | "dns" 또는 "wellknown". |
expires_at | 검증 만료 시각 (현재 정책 기준 stamp 시 자동 부여). |
status | 아래 status enum 4가지 중 하나. |
status enum
Security::RedirectUriVerificationStatus.derive 가 단일 진실 (server/app/services/security/redirect_uri_verification_status.rb):
| status | 조건 |
|---|---|
unverifiable_host | tier 가 localhost / custom_scheme / unknown 중 하나 — DNS/HTTP 챌린지가 의미 없음. |
unverified | verified_at 가 비어 있음. |
verified | verified_at 가 있고 expires_at 가 비어 있거나 미래. 단, expires_at 파싱 실패는 "TTL 없음" 으로 취급 (verified 유지) — parse 글리치로 운영자 확인된 URI 가 silent downgrade 되지 않도록. |
expired | verified_at 가 있고 expires_at 가 과거. |
POST 응답
POST /developer/applications/42/verify_redirect_uri
Content-Type: application/json
{ "uri": "https://app.example.com/auth/callback" }성공:
{
"uri": "https://app.example.com/auth/callback",
"verified": true,
"method": "dns",
"reason": null,
"detail": null
}실패 예 (DNS 도 well-known 도 fail):
{
"uri": "https://app.example.com/auth/callback",
"verified": false,
"method": null,
"reason": "unverified",
"detail": "dns=dns: no matching TXT record wellknown=wellknown: body mismatch"
}reason 값
Security::RedirectUriVerifier 가 반환하는 reason 심볼 (redirect_uri_verifier.rb):
| reason | 의미 |
|---|---|
unparseable_uri | URI.parse 가 실패. |
unverifiable_host | 호스트가 비어 있거나, IP 리터럴, 또는 localhost / RFC1918 / link-local — pre-gate 차단. |
unverified | DNS TXT 와 well-known 모두 챌린지 mismatch (정상 시도였으나 일치 못함). |
dns_no_record | DNS 가 NXDOMAIN / SERVFAIL. |
dns_timeout | DNS resolve 타임아웃 (3초 + 1초 retry). |
dns_error | 기타 DNS 예외. detail 에 클래스명. |
ssrf_blocked | well-known 단계에서 WebhookUrlValidator.resolve_safe! 가 차단 (DNS rebinding / private IP 등). |
not_found | well-known URL 이 4xx (404 등). |
server_error | well-known URL 이 5xx. |
redirect_not_allowed | well-known 응답이 3xx (리디렉트는 거부). |
tls_invalid | TLS 핸드셰이크 / cert SAN 불일치. |
timeout | well-known 읽기 타임아웃 (5초). |
body_too_large | well-known body 가 256바이트 초과. |
http_error | 기타 HTTP/소켓 오류. |
POST 에러 응답
| HTTP | 응답 body | 원인 |
|---|---|---|
| 422 | { "error": "uri_not_in_application" } | body 의 uri 가 해당 application 의 redirect_uri_strings 에 없음. |
| 429 | { "error": "rate_limited" } | 같은 (app, uri) 에 대해 60초 안에 재시도. |
| 401 | { "error": "step_up_required" } / { "error": "otp_not_enrolled" } | (developer) OTP 등록 안 됨 또는 최근 5분 내 재인증 없음. |
| 403 | { "error": "forbidden" } | (developer) 보이긴 하나 manage 권한 없음. |
| 404 | (없음) | 다른 org / 멤버십 없음. |
SSRF 및 검증 안전 규칙
Security::RedirectUriVerifier 는 다음을 강제합니다 (라인 24-129, 162-293):
- Pre-gate (네트워크 호출 전):
localhost/*.localhost/ IP 리터럴 (IPv4·IPv6) / RFC1918 / RFC4193 / link-local / metadata IP 차단. - DNS:
Resolv::DNS.open(timeouts: [3, 1])— 짧고 결정적._logi-verify.<host>TXT 만 조회. - well-known:
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). - rate limit:
verify_attempt:<app.id>:<sha256(uri)>캐시 키에unless_exist: true로 atomic 작성. TOCTOU 없음.
CLI 와의 관계
logi apps verify <app_id> <uri> CLI 는 이 POST endpoint 의 thin wrapper 입니다. 운영자가 직접 raw HTTP 를 칠 일은 거의 없고, 콘솔의 검증 버튼이나 CLI 가 호출합니다. RP integration runbook 의 자동화 경로 (CI 에서 신규 redirect_uri 등록 직후 검증 트리거) 도 같은 endpoint 를 사용합니다.
관련 commits
77c7adaa— Wave 5 F1 status enum 통합 (admin/dev 단일 source).218ea65—RedirectUriVerificationStatus추출 + malformedexpires_at처리 정책.659d21e— DNS 우선 / well-known fallback 2-prong 검증기 land.2c718fa— pinned-IP TLS + SNI cert 체크 + 3xx 거부 + body 256B 캡.856f5ed— POST endpoint 의 60초 atomic rate limit + audit hook.
Related
- RP Active Health Check —
/.well-known/logi-rp-health활성 헬스 체크. redirect_uri verification 과 보완 관계 (호스트 소유권 ↔ 통합 alive). - PKCE — redirect_uri 정책 일반.
- Public Clients — custom-scheme redirect_uri 의 보안 모델 (이 endpoint 가
unverifiable_host로 분류하는 케이스).