Skip to content

redirect_uri auto-verification (DNS / well-known)

logi verifies host ownership of a registered OAuth redirect_uri via a DNS TXT or .well-known HTTPS challenge. The result is stamped onto OauthApplication#redirect_uri_metadata, and the RP console's card, the admin iOS app, and the logi apps verify CLI all call the same endpoint and see the same status enum (Security::RedirectUriVerificationStatus is the single source of truth).

Key points

  • Two-pronged challenge: DNS TXT first (cheap / cacheable) → on failure, a well-known HTTPS fallback.
  • HMAC challenge value: the hex of HMAC-SHA256(secret_key_base + ":logi-redirect-verify", "{app.id}:{uri}"). It is deterministic, so it is stable without adding a DB column.
  • SSRF defense: localhost / RFC1918 / IP literals / metadata IPs are blocked at a pre-gate before any network call.
  • Rate limit: once per 60 seconds per (app, uri). Rails.cache.write(..., unless_exist: true) is atomic.
  • Paired with the manual CLI: logi apps verify <app_id> <uri> is a thin wrapper around this endpoint. Users rarely call it directly.

Verification flow

mermaid
sequenceDiagram
  autonumber
  participant Console as RP console / CLI / Admin iOS
  participant API as logi server
  participant DNS as DNS resolver
  participant RP as RP host (HTTPS)

  Console->>API: GET .../redirect_uri_verifications
  API-->>Console: { verifications: [ {uri, challenge_dns_record, challenge_wellknown_*, status} ] }
  Note over Console: The RP operator deploys a DNS TXT or<br/>.well-known file
  Console->>API: POST .../verify_redirect_uri { uri }
  API->>API: rate-limit check (60s per (app, uri))
  API->>API: pre-gate (block 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 failed
    API->>RP: GET https://<host>/.well-known/logi-verification.txt
    Note over API,RP: pinned-IP TLS + SNI verification<br/>reject redirects · body capped at 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
  end

Endpoints

Developer console (for RP owners)

MethodPathAuth
GET/developer/applications/:id/redirect_uri_verificationsSession cookie (RP owner / org member)
POST/developer/applications/:id/verify_redirect_uriSession cookie + manage permission + OTP re-authentication within the last 5 minutes

Route definitions — config/routes.rb:524-525:

ruby
get :redirect_uri_verifications
post :verify_redirect_uri
  • Authz (developer/applications_controller.rb):
    • set_applicationscoped_applications.find404 if not visible (different org / no membership).
    • GET is visibility-only — no manage check (the same for admins).
    • POST additionally requires authorize_app_management_json! → 403 forbidden, and enforce_recent_otp_for_sensitive_json → 401 step_up_required (or otp_not_enrolled).
  • Audit: the result is recorded via Authentication::AuditLogger.record!(event_type: "redirect_uri_verify_dev", ...). The event_type must be registered in the AuthenticationAuditLog::EVENT_TYPES whitelist.

For the admin iOS app (/api/v1/admin)

MethodPathAuth
GET/api/v1/admin/applications/:id/redirect_uri_verificationsadmin session token (no step-up)
POST/api/v1/admin/applications/:id/verify_redirect_uriadmin session + step-up

Route definitions — config/routes.rb:860-861:

ruby
get :redirect_uri_verifications
post :verify_redirect_uri

The response schema and the core behavior are byte-for-byte identical to the developer side (build_verification_entry has the same implementation on both, and Security::RedirectUriVerificationStatus is the single status enum). Only the auth / audit differ:

  • Audit channel: AdminAuditLog.create!(action: "admin.application.redirect_uri.verify", ...).

GET response

http
GET /developer/applications/42/redirect_uri_verifications
json
{
  "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"
    }
  ]
}
FieldDescription
uriThe original registered redirect_uri.
tierClassification by Oauth::RedirectUriClassifierhttps_public / https_org / localhost / custom_scheme / unknown.
challenge_dns_recordThe TXT record string for the RP operator to publish. null if the host is empty or an IP literal.
challenge_wellknown_urlThe absolute URL where the well-known file should go.
challenge_wellknown_bodyThe challenge value (HMAC hex) to write verbatim into the well-known file.
verified_atThe time of the last successful stamp (ISO 8601). null if unverified.
verification_method"dns" or "wellknown".
expires_atThe verification expiry time (assigned automatically at stamp time per the current policy).
statusOne of the four status enum values below.

status enum

Security::RedirectUriVerificationStatus.derive is the single source of truth (server/app/services/security/redirect_uri_verification_status.rb):

statusCondition
unverifiable_hosttier is one of localhost / custom_scheme / unknown — a DNS/HTTP challenge is meaningless.
unverifiedverified_at is empty.
verifiedverified_at is present and expires_at is empty or in the future. Note that a failure to parse expires_at is treated as "no TTL" (it stays verified) — so that a URI an operator has confirmed is not silently downgraded by a parse glitch.
expiredverified_at is present and expires_at is in the past.

POST response

http
POST /developer/applications/42/verify_redirect_uri
Content-Type: application/json

{ "uri": "https://app.example.com/auth/callback" }

Success:

json
{
  "uri": "https://app.example.com/auth/callback",
  "verified": true,
  "method": "dns",
  "reason": null,
  "detail": null
}

Failure example (both DNS and well-known fail):

json
{
  "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 values

The reason symbols returned by Security::RedirectUriVerifier (redirect_uri_verifier.rb):

reasonMeaning
unparseable_uriURI.parse failed.
unverifiable_hostThe host is empty, an IP literal, or localhost / RFC1918 / link-local — blocked at the pre-gate.
unverifiedBoth the DNS TXT and the well-known challenge mismatched (a valid attempt that didn't match).
dns_no_recordDNS returned NXDOMAIN / SERVFAIL.
dns_timeoutDNS resolution timed out (3s + 1s retry).
dns_errorSome other DNS exception. The class name is in detail.
ssrf_blockedAt the well-known stage, WebhookUrlValidator.resolve_safe! blocked it (DNS rebinding / private IP, etc.).
not_foundThe well-known URL returned a 4xx (404, etc.).
server_errorThe well-known URL returned a 5xx.
redirect_not_allowedThe well-known response was a 3xx (redirects are rejected).
tls_invalidTLS handshake / cert SAN mismatch.
timeoutThe well-known read timed out (5s).
body_too_largeThe well-known body exceeded 256 bytes.
http_errorSome other HTTP/socket error.

POST error responses

HTTPResponse bodyCause
422{ "error": "uri_not_in_application" }The uri in the body is not in the application's redirect_uri_strings.
429{ "error": "rate_limited" }A retry within 60 seconds for the same (app, uri).
401{ "error": "step_up_required" } / { "error": "otp_not_enrolled" }(developer) OTP not enrolled, or no re-authentication within the last 5 minutes.
403{ "error": "forbidden" }(developer) Visible, but no manage permission.
404(none)Different org / no membership.

SSRF and verification safety rules

Security::RedirectUriVerifier enforces the following (lines 24-129, 162-293):

  1. Pre-gate (before any network call): blocks localhost / *.localhost / IP literals (IPv4·IPv6) / RFC1918 / RFC4193 / link-local / metadata IPs.
  2. DNS: Resolv::DNS.open(timeouts: [3, 1]) — short and deterministic. Queries only _logi-verify.<host> TXT.
  3. well-known: resolve once via WebhookUrlValidator.resolve_safe! → connect directly to that IP, presenting the original host as the SNI → verify the cert's CN/SAN against the original host (post_connection_check). This shuts down DNS rebinding.
  4. Reject redirects: a 3xx response fails on the spot (redirect_not_allowed).
  5. Body cap: aborts immediately if it exceeds 256 bytes (body_too_large).
  6. Rate limit: written atomically to the cache key verify_attempt:<app.id>:<sha256(uri)> with unless_exist: true. No TOCTOU.

Relationship to the CLI

The logi apps verify <app_id> <uri> CLI is a thin wrapper around this POST endpoint. Operators rarely hit raw HTTP directly; the console's verify button or the CLI calls it. The automation path in the RP integration runbook (triggering verification right after registering a new redirect_uri in CI) also uses the same endpoint.

  • 77c7adaa — Wave 5 F1 status enum unification (single source for admin/dev).
  • 218ea65 — extract RedirectUriVerificationStatus + the malformed expires_at handling policy.
  • 659d21e — land the DNS-first / well-known-fallback two-prong verifier.
  • 2c718fa — pinned-IP TLS + SNI cert check + 3xx rejection + 256B body cap.
  • 856f5ed — the POST endpoint's 60-second atomic rate limit + audit hook.
  • RP Active Health Check — the /.well-known/logi-rp-health active health check. It complements redirect_uri verification (host ownership ↔ integration alive).
  • PKCE — redirect_uri policy in general.
  • Public Clients — the security model of custom-scheme redirect_uris (the case this endpoint classifies as unverifiable_host).

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