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. The RP console's card and the admin iOS app call this trigger endpoint; the logi apps verify CLI does not trigger a verification and only reads status from the app payload (PAK). All three see the same status enum computed by Security::RedirectUriVerificationStatus (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. - The CLI only observes:
logi apps verify <id> -r <uri>does not call this trigger endpoint. The CLI authenticates with a PAK and cannot reach the session-gated trigger; instead it reads verification status from the app payload and polls to completion (verified/failed) with--poll. Triggering happens on the console (session + OTP).
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 <id> -r <uri> CLI does not call this POST trigger endpoint. Triggering a verification is a sensitive action that needs a session + OTP, so it lives only on the console (and the admin iOS app); the CLI authenticates with a PAK and cannot reach that gate.
Instead the CLI is an observer. The app payload (GET /api/v1/applications/:id, PAK) exposes a per-uri verification summary (redirect_uri_verifications: verified_at plus the transient last_attempt queued/done state), which the CLI reads to show each redirect_uri's status and, with --poll, watch queued → done (verified or failed). When a verification is needed, it prints the console URL.
# Status for every redirect_uri
logi apps verify <id>
# One uri + a console trigger link
logi apps verify <id> -r https://app.example.com/cb
# After triggering on the console, poll to completion
logi apps verify <id> -r https://app.example.com/cb --pollThis design adds no new auth infrastructure (the attack surface is unchanged). The domain-ownership-proof nature of verification stays behind the console's session + OTP path.
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).