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
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
endEndpoints
Developer console (for RP owners)
| Method | Path | Auth |
|---|---|---|
GET | /developer/applications/:id/redirect_uri_verifications | Session cookie (RP owner / org member) |
POST | /developer/applications/:id/verify_redirect_uri | Session cookie + manage permission + OTP re-authentication within the last 5 minutes |
Route definitions — config/routes.rb:524-525:
get :redirect_uri_verifications
post :verify_redirect_uri- Authz (
developer/applications_controller.rb):set_application→scoped_applications.find→ 404 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!→ 403forbidden, andenforce_recent_otp_for_sensitive_json→ 401step_up_required(orotp_not_enrolled).
- Audit: the result is recorded via
Authentication::AuditLogger.record!(event_type: "redirect_uri_verify_dev", ...). The event_type must be registered in theAuthenticationAuditLog::EVENT_TYPESwhitelist.
For the admin iOS app (/api/v1/admin)
| Method | Path | Auth |
|---|---|---|
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 |
Route definitions — config/routes.rb:860-861:
get :redirect_uri_verifications
post :verify_redirect_uriThe 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
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"
}
]
}| Field | Description |
|---|---|
uri | The original registered redirect_uri. |
tier | Classification by Oauth::RedirectUriClassifier — https_public / https_org / localhost / custom_scheme / unknown. |
challenge_dns_record | The TXT record string for the RP operator to publish. null if the host is empty or an IP literal. |
challenge_wellknown_url | The absolute URL where the well-known file should go. |
challenge_wellknown_body | The challenge value (HMAC hex) to write verbatim into the well-known file. |
verified_at | The time of the last successful stamp (ISO 8601). null if unverified. |
verification_method | "dns" or "wellknown". |
expires_at | The verification expiry time (assigned automatically at stamp time per the current policy). |
status | One 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):
| status | Condition |
|---|---|
unverifiable_host | tier is one of localhost / custom_scheme / unknown — a DNS/HTTP challenge is meaningless. |
unverified | verified_at is empty. |
verified | verified_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. |
expired | verified_at is present and expires_at is in the past. |
POST response
POST /developer/applications/42/verify_redirect_uri
Content-Type: application/json
{ "uri": "https://app.example.com/auth/callback" }Success:
{
"uri": "https://app.example.com/auth/callback",
"verified": true,
"method": "dns",
"reason": null,
"detail": null
}Failure example (both DNS and 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 values
The reason symbols returned by Security::RedirectUriVerifier (redirect_uri_verifier.rb):
| reason | Meaning |
|---|---|
unparseable_uri | URI.parse failed. |
unverifiable_host | The host is empty, an IP literal, or localhost / RFC1918 / link-local — blocked at the pre-gate. |
unverified | Both the DNS TXT and the well-known challenge mismatched (a valid attempt that didn't match). |
dns_no_record | DNS returned NXDOMAIN / SERVFAIL. |
dns_timeout | DNS resolution timed out (3s + 1s retry). |
dns_error | Some other DNS exception. The class name is in detail. |
ssrf_blocked | At the well-known stage, WebhookUrlValidator.resolve_safe! blocked it (DNS rebinding / private IP, etc.). |
not_found | The well-known URL returned a 4xx (404, etc.). |
server_error | The well-known URL returned a 5xx. |
redirect_not_allowed | The well-known response was a 3xx (redirects are rejected). |
tls_invalid | TLS handshake / cert SAN mismatch. |
timeout | The well-known read timed out (5s). |
body_too_large | The well-known body exceeded 256 bytes. |
http_error | Some other HTTP/socket error. |
POST error responses
| HTTP | Response body | Cause |
|---|---|---|
| 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):
- Pre-gate (before any network call): blocks
localhost/*.localhost/ IP literals (IPv4·IPv6) / RFC1918 / RFC4193 / link-local / metadata IPs. - DNS:
Resolv::DNS.open(timeouts: [3, 1])— short and deterministic. Queries only_logi-verify.<host>TXT. - 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. - Reject redirects: a 3xx response fails on the spot (
redirect_not_allowed). - Body cap: aborts immediately if it exceeds 256 bytes (
body_too_large). - Rate limit: written atomically to the cache key
verify_attempt:<app.id>:<sha256(uri)>withunless_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.
Related commits
77c7adaa— Wave 5 F1 status enum unification (single source for admin/dev).218ea65— extractRedirectUriVerificationStatus+ the malformedexpires_athandling 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.
Related
- RP Active Health Check — the
/.well-known/logi-rp-healthactive 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).