Dual-Channel: 비밀번호 + 푸시 알림 병행 (Recommended)
Source:
oauth/dual-channel-password-login.md· Live: https://docs.1pass.dev/oauth/dual-channel-password-login LLM-sanitized: internal links absolutized, VitePress containers → admonitions, line numbers in the Jump-to Index reference this rendered file (1-indexed).
📍 Jump-to Index
- L26-L43: ## 왜 dual-channel 인가
- L44-L71: ## UI 패턴
- L72-L111: ## 1pass 내부 구현 (
app/views/sessions/new.html.erb) - L112-L123: ## 자체 로그인 UI 를 구축하는 RP 가 따라야 할 패턴
- L124-L162: ## ⚠️ status/complete 는 쿠키-바인딩 — Set-Cookie 전부 echo
- L163-L171: ## 보안 고려
- L172-L178: ## 안티패턴
- L179-L184: ## 관련 문서
Dual-Channel: 비밀번호 + 푸시 알림 병행 (Recommended)
Recommendation — 1pass 의
/session/new우측 비밀번호 컬럼은 "확인하기" 버튼 한 번으로 logi 앱에 푸시를 보내면서 동시에 비밀번호 입력으로도 로그인을 진행할 수 있는 dual-channel 패턴을 채택합니다. 같은 패턴을 자체 로그인 UI 를 구축하는 RP 에도 권장합니다.
왜 dual-channel 인가
단일 채널은 각자 실패 케이스를 안 막아 줍니다.
| 단일 채널 | 실패 시나리오 |
|---|---|
| 앱 Universal Link 만 | 카톡·네이버·페이스북 인앱 브라우저에서 redirect 차단 |
| 푸시 알림만 (1탭 승인) | 푸시 미수신 (배터리 절약 / Doze / 권한 거부 / 토큰 만료) |
| 비밀번호만 | 폰에 비밀번호 매니저 없을 때 모바일 타이핑 고통 + audit trail 없음 |
병렬로 두 경로를 살려두면 사용자는:
- 푸시가 오면 → 폰에서 [승인] 1 탭 → 자동 redirect
- 푸시가 늦거나 안 오면 → 그 자리에서 비밀번호 입력 → 정상 로그인
- 비밀번호 모르면 → 푸시 기다리거나 magic-link/패스키 등 다른 경로
먼저 끝나는 쪽이 우선. 둘 다 살아 있다는 것 자체가 보안 알림(audit) 효과도 줍니다 — 본인 아닌 시도면 푸시로 즉시 인지 가능.
UI 패턴
┌─ 이메일로 로그인 ──────────────────────────┐
│ │
│ 이메일 │
│ [you@example.com ] │
│ │
│ [📲 logi 앱으로 확인하기] │
│ 앱에 알림이 가요. 앱에서 승인하거나 │
│ 아래 비밀번호로 로그인할 수 있어요. │
│ │
│ 비밀번호 │
│ [•••••••• ] │
│ │
│ [로그인] │
│ │
│ 비밀번호를 잊으셨나요? │
└────────────────────────────────────────────┘핵심:
- 하나의 이메일 필드를 두 경로가 공유 — 사용자가 같은 값을 두 번 입력하지 않음
- "확인하기" 는 secondary button (text-xs / glass-button) — 비밀번호 + 로그인이 여전히 primary path
- 상태 표시는 inline — 폼 아래 작은 영역에 "✓ 알림 발송됨" / "✗ 발송 실패" 등
- 백그라운드 폴링 — 사용자가 비번 입력하는 동안에도
/oauth/push/:id/status폴링 유지. 승인 들어오면 자동 redirect
1pass 내부 구현 (app/views/sessions/new.html.erb)
<div class="glass-card"
data-test-id="email-fallback-column"
<% if @qr_oauth_params.present? %>
data-controller="push-approval"
data-push-approval-start-url-value="<%= oauth_push_start_path %>"
data-push-approval-status-url-template-value="<%= oauth_push_status_path(':id') %>"
data-push-approval-complete-url-template-value="<%= oauth_push_complete_path(':id') %>"
data-push-approval-oauth-params-value="<%= @qr_oauth_params.to_json %>"
<% end %>>
<%= form_with url: session_url, local: true do |form| %>
<%= form.email_field :email_address,
data: { push_approval_target: "email" } %>
<% if @qr_oauth_params.present? %>
<button type="button"
data-action="click->push-approval#start"
data-push-approval-target="submit">
📲 logi 앱으로 확인하기
</button>
<div data-push-approval-target="statusBox" hidden></div>
<div data-push-approval-target="errorBox" hidden></div>
<% end %>
<%= form.password_field :password %>
<%= form.submit "로그인" %>
<% end %>
</div>요점:
data-controller="push-approval"가 RP 콘텍스트(@qr_oauth_params) 있을 때만 mount- 이메일 input 이 폼 안에 있지만
data-push-approval-target="email"로 Stimulus 와도 공유 - "확인하기" 는
type="button"이라 폼 submit 안 일어남 — Stimulus 만 트리거 - "로그인" submit 은 원래대로 평범한
POST /session— Stimulus 무관
자체 로그인 UI 를 구축하는 RP 가 따라야 할 패턴
RP 가 1pass /oauth/authorize consent 화면을 거치지 않고 embedded auth 로 자체 로그인 폼을 만든다면:
- 이메일 필드 + secondary "확인하기 (선택)" 버튼
- 클릭 시 브라우저에서
POST /oauth/push/start호출 — body 에{ email, client_id, redirect_uri, code_challenge, code_challenge_method, state, scope }(OAuth authorize params 그대로 전달). 응답{ request_id, expires_in }. - 반환된
request_id로 클라이언트가GET /oauth/push/:id/status폴링 (1.5s 간격) approved응답 시 →window.location = /oauth/push/:id/complete(서버가 grant 발급 + RP redirect_uri 로 302 bounce). 모바일 측 승인은POST /api/v1/push_approvals/:id/approve(logi 앱이 호출, 외부 RP 무관).- 동시에 비밀번호/패스키 등 본인 인증 경로 살려둬서 사용자가 어느 쪽이든 먼저 끝낼 수 있게
Push 승인 흐름 자체의 전체 시퀀스 다이어그램은 Demo Page Walkthrough — Push Approval 참고. 서버 측 endpoint 는 Oauth::PushApprovalsController (POST /oauth/push/start, GET /oauth/push/:id/status, GET /oauth/push/:id/complete) 와 모바일 측 Api::V1::PushApprovalsController (POST /api/v1/push_approvals/:id/{approve,deny}).
⚠️ status/complete 는 쿠키-바인딩 — Set-Cookie 전부 echo
POST /oauth/push/start 응답은 암호화 쿠키 여러 개(push_request_uuid + push_browser_nonce + synthetic shape-parity 쿠키들)를 Set-Cookie 로 내려줍니다. GET /oauth/push/:id/status 와 /complete 는 이 쿠키들을 전부 다시 받아야 통과합니다. 특히 push_browser_nonce 가 빠지면 매 폴링이 400 Bad Request 가 되어 브라우저가 "폰 확인 대기" 에서 영영 못 벗어납니다 (nonce = 소유 증명. uuid 는 추측 불가하지만 그것만으로는 제3자도 폴링할 수 있어 nonce 로 binding — qr-login 과 동일 모델).
Same-origin RP (1pass 도메인 위 embedded auth, 또는 RP 자체 도메인에서 직접 호출): 브라우저가 쿠키를 자동 관리하므로 추가 작업 없음. fetch 만 credentials: 'same-origin' (또는 include) 로 호출하면 됩니다.
Cross-site RP (예: app.example.com 브라우저가 api.1pass.dev 호출): start/status 쿠키는 SameSite=Lax 라 cross-site fetch 폴링(non-navigational)에는 실리지 않습니다. 브라우저 직접 호출 대신 RP 백엔드가 서버사이드 프록시로 start → status → complete 를 중계하고, RP 가 직접 쿠키를 보관·echo 하세요.
🚨 HTTP 클라이언트 Set-Cookie 콤마-join 함정. Faraday 를 비롯한 다수 HTTP 클라는 동일 이름(
Set-Cookie) 다중 응답 헤더를 콤마로 이어붙인 단일 문자열로 돌려줍니다. 게다가expires=Wed, 18-Jun-2026 07:25:24 GMT의 날짜에도 콤마가 있어 단순split(',')로는 못 쪼갭니다.header.split(';').first처럼 첫 쿠키만 집으면push_request_uuid만 남고 nonce 가 유실되어 로그인이 무한 대기합니다. 콤마 뒤에 곧바로name=토큰이 오는 경계에서만 분리하세요:ruby# Set-Cookie 콤마-join 헤더 → 개별 쿠키. expires 날짜 콤마로 오분리 방지. COOKIE_PAIR_BOUNDARY = /,\s*(?=[^=;,\s]+=)/ def parse_set_cookies(header) return {} if header.blank? Array(header).flat_map { |h| h.to_s.split(COOKIE_PAIR_BOUNDARY) } .each_with_object({}) do |raw, acc| name, value = raw.split(';').first.to_s.split('=', 2) acc[name.to_s.strip] = value if name.present? && value.present? end end그리고 status/complete 호출 시 보관한 쿠키를 전부
Cookie헤더로 echo 합니다.
보안 고려
| 항목 | 안전 보장 |
|---|---|
| 이메일 enumeration | /oauth/push/start 는 등록 안 된 이메일에도 200 반환 + synthetic request_id 발급. 폴링은 영원히 pending 유지. enumeration 차단. |
| 푸시 폭탄 (DoS) | PushApprovalRequest 에 cool-down (TTL 윈도우 내 같은 이메일 재시도 제한). 인입 단계에서 rate-limit. |
| MITM / 채널 혼선 | request_id 는 unguessable UUID. /oauth/push/:id/complete 는 발급된 client 의 cookie 와 OAuth state 둘 다 검증. |
| 본인 부재 시 비번 노출 | 푸시 알림 본문에 "비밀번호로 로그인 시도 중" 명시 + 30초 내 [차단] 탭 가능. 본인 아닌 시도 즉시 차단. |
안티패턴
- ❌ "확인하기" 가 비번 필드를 disable — 푸시 미수신 사용자가 빠져나갈 수 없음
- ❌ "확인하기" 가 푸시 발송 후 자동 reload — 백그라운드 폴링 잃음, 비번 입력 중인 사용자에게 disruptive
- ❌ "확인하기" 가 submit 버튼과 같은 위치/스타일 — 사용자가 헷갈림. secondary affordance 로 명확히 demote
- ❌ 이메일 필드 두 개 — 같은 값을 두 번 입력시키지 말 것
관련 문서
- Demo Page Walkthrough — 데모 RP 의 실제 흐름 + push approval sequence
- Public Clients — secret 없는 RP 의 자체 인증 패턴
- Post-login Destination — 로그인 완료 후 라우팅 규칙