QR login (app push approval)
The same pattern as WhatsApp Web · Slack · Discord. The user sees a QR code on a website, scans it with the mobile logi app, confirms their identity, and approves — and the browser logs in automatically.
Impact on the RP
None. QR login is one of the login options the logi /oauth/authorize page offers to users. The response the RP receives is the same redirect_uri?code=&state= as the standard Authorization Code Flow. No separate SDK change, client configuration, or extra redirect_uri is required.
When it's enabled
A "📱 Log in with QR" button appears on the logi login page (/session/new) and the OAuth consent screen (/oauth/authorize). The QR flow starts only when the user triggers it directly (no automatic redirect).
Because iOS does not open Universal Links from server-side redirect chains, the standard web→app auto-redirect is either unsafe or non-functional. QR works around this limitation by providing an out-of-band channel that the user explicitly starts.
Sequence diagram
sequenceDiagram
autonumber
participant C as RP app
participant B as Browser
participant L as logi server
participant A as logi mobile app
C->>B: GET /oauth/authorize?client_id&redirect_uri&state&code_challenge&scope
B->>L: The same request arrives
L-->>B: Consent page (or /session/new)
B->>L: Click "Log in with QR" → POST /oauth/qr/start (OAuth params)
L->>L: Create QrLoginSession + signed cookie (uuid + browser_nonce)
L-->>B: { session_id, qr_payload, cable_path }
B->>B: Render the QR canvas + subscribe to ActionCable (/cable, params: { session_id })
A->>A: Scan the QR with the camera → { session: uuid, domain: "api.1pass.dev" }
A->>L: GET /api/v1/oauth/qr/:id (Bearer PAK)
L-->>A: { application, scopes, browser: { label, ip } }
A->>L: POST /api/v1/oauth/qr/:id/scan
L-->>B: Cable push { status: "scanned" }
A-->>A: Show OAuthConsentView (RP name · scopes · browser)
A->>L: POST /api/v1/oauth/qr/:id/approve (Bearer PAK)
L->>L: session.transition!(scanned → approved, approved_user)
L-->>B: Cable push { status: "approved" }
B->>L: GET /oauth/qr/:id/complete (verify the signed cookie)
L->>L: transaction { transition!(approved → completed); OauthAccessGrant.create!; Consent.grant!; AuditLogger; LoginLog }
L-->>B: 302 redirect_uri?code=&state=
B->>C: The redirect arrives — re-enter the standard OAuth flow
C->>L: POST /oauth/token (code_verifier)
L-->>C: access_token + refresh_token + id_token?Security model
| Threat | Defense |
|---|---|
| QR theft / capture | session_uuid is single-use (cannot be reused after completed) + a 10-minute TTL + a sweeper job for expiry |
Browser hijacking (calling /complete from another device) | Two signed cookies are verified — qr_session_uuid + qr_browser_nonce (32B random). Bound by a secret nonce, not by UA/IP |
| App impersonation (approving without a PAK) | POST /approve requires a Bearer PAK and rejects anonymous users |
State race (concurrent /approve × 2) | The WHERE status = 'scanned' UPDATE status = 'approved' atomic transition. affected_rows=0 → 409 |
| The app impersonating an RP | The app hard-codes verification of the QR payload's domain (rejects anything other than api.1pass.dev) |
| Duplicate grant via re-calling /complete | The transition!(approved → completed) inside the transaction succeeds only once |
| CSRF (POST /oauth/qr/start) | Rails CSRF token (X-CSRF-Token header) |
| Brute force / DoS | Rack Attack — start 10/min/IP, status 60/min/IP |
Not yet applied in v0.x (v2.x roadmap)
- mTLS / DPoP — app-server mutual cert binding
- Signed QR payload (the app verifies logi's ECDSA signature)
- Suspicious-login detection (geolocation change, impossible travel)
Expiry / denial / errors
| Situation | Response the RP receives |
|---|---|
| The user chose "Deny" in the app | 302 redirect_uri?error=access_denied&state=... (standard) |
| The QR expired (10 minutes) | 302 redirect_uri?error=access_denied&state=... (standard) |
| The browser_nonce cookie was tampered with | 400 Bad Request (no RP redirect, the user logs in again) |
| An attempt to reuse the same session | 302 redirect_uri?error=access_denied&state=... |
access_denied is the standard code from RFC 6749 §4.1.2.1. Your OAuth library handles it exactly as is.
What the RP needs to do: nothing
Steps 4–13 of the sequence diagram above are all logi's responsibility. From the RP's perspective, it is identical to the standard Authorization Code + PKCE flow.
// Your existing OAuth code, unchanged
window.location.href = `https://api.1pass.dev/oauth/authorize?${qs}`;
// ...
const { code, state } = await receiveRedirect();
const { access_token } = await fetch("/oauth/token", { /* ... */ });The RP does not need to know whether the user logged in via QR or with a password, and the payload of the JWT access_token is the same either way.
Mobile app SDK API (logi 1pass app only)
The four endpoints on the QR side are used by the logi iOS/Android app. They are not exposed to external RPs (PAK Bearer auth + app registration required).
For the full spec, see the /api/v1/oauth/qr/* section in API Reference — Internal QR endpoints.