테마
Demo 페이지 둘러보기
demo.1pass.dev 는 1pass IdP 통합 모드 6 종을 직접 눌러볼 수 있는 walking sample. 외부 자료에서 시나리오별로 deep-link 하기 좋습니다. 호스트는 start.1pass.dev 세션 쿠키 누출을 막기 위해 분리되어 있습니다.
시나리오 카탈로그
| URL | 컨트롤러 | 대상 / 한 줄 설명 |
|---|---|---|
/ | Web::Demo::IndexController#show | 카탈로그 허브 (6 모드 비교) |
/oauth | Web::Demo::OauthController#show | 데스크톱 OAuth 2.0 + PKCE 표준 흐름 |
/qr | Web::Demo::QrController#show | 데스크톱 ↔ iPhone QR 핸드오프 (DemoPairingSession + ActionCable) |
/app-clip | Web::Demo::AppClipController#show | iOS App Clip (앱 미설치 사용자, .Clip 번들, AASA appclips.apps) |
/ios | Web::Demo::IosController#show | iOS 네이티브 (앱 설치 후, Universal Link + Swift SDK) |
/android | Web::Demo::AndroidController#show | Android (Intent setPackage first-try + Custom Tabs fallback) |
/mac | Web::Demo::MacController#show | macOS (.mac 번들, Universal Link + 키체인 PKCE) |
각 시나리오 페이지는 narrative + 기술 흐름 box + CTA 구조의 읽기 전용 랜딩. CTA 가 /demo/* 백엔드 액션을 호출해 실제 PKCE 라운드트립을 시작합니다.
시나리오 구분 포인트
/app-clipvs/ios: App Clip = 앱 미설치 사용자용 (QR/NFC 마케팅 면),/ios= 앱 설치 사용자 RP 내부 인증 (Swift SDK 만 붙이면 끝)./iosfirst-try 패턴:UIApplication.open(URL:, options: [.universalLinksOnly: true])→ false 반환 시 App Clip/Safari fallback./android:Intent(ACTION_VIEW, uri).setPackage("com.dcodelabs.logi")→ActivityNotFoundExceptioncatch →CustomTabsIntentfallback. iOS App Clip 같은 메커니즘 없음 → 미설치 시 Play Store 안내 또는 웹 Custom Tabs.- App Clip invocation URL:
api.1pass.dev/demo/clip(AASA 등록). 비 iOS 디바이스가 같은 QR 스캔 시demo.1pass.dev/로 301 redirect (dead link 방지).LOGI_API_HOSTenv 로 staging override 가능.
라우트 매핑
config/routes.rb 의 demo_host_constraint 블록:
ruby
constraints(demo_host_constraint) do
# Scenario landing pages
root to: "web/demo/index#show", as: :demo_host_root
get "oauth" => "web/demo/oauth#show", as: :demo_oauth
get "qr" => "web/demo/qr#show", as: :demo_qr
get "ios" => "web/demo/ios#show", as: :demo_ios
get "android" => "web/demo/android#show", as: :demo_android
get "app-clip" => "web/demo/app_clip#show", as: :demo_app_clip
get "mac" => "web/demo/mac#show", as: :demo_mac
# OAuth back-end actions (PKCE). /demo 는 /oauth alias (App Clip QR back-compat)
get "demo" => "web/demo#show", as: :demo
get "demo/start" => "web/demo#start", as: :demo_start
get "demo/callback" => "web/demo#callback", as: :demo_callback
# Mac back-end
get "demo/mac/install" => "web/demo#mac_install", as: :demo_mac_install
post "demo/mac/notify" => "web/demo#mac_notify", as: :demo_mac_notify
# QR pairing back-end
get "demo/pair/:pair_id" => "web/demo_pair#show", as: :demo_pair
get "demo/pair/:pair_id/result" => "web/demo_pair#result", as: :demo_pair_result
end핵심 파일
app/controllers/web/demo/{index,oauth,qr,app_clip,ios,android,mac}_controller.rb— 시나리오 랜딩 (allow_unauthenticated_access,layout "demo")app/controllers/web/demo_controller.rb— back-compat/demo/*액션app/controllers/web/demo_pair_controller.rb— 데스크톱 측 페어링app/controllers/api/v1/demo_pair_controller.rb— iPhone 측 완료 엔드포인트app/views/layouts/demo.html.erb— importmap 없는 CSP-clean layoutapp/models/demo_pairing_session.rb— 페어링 상태 머신app/channels/demo_pairing_channel.rb— 데스크톱 ActionCable 구독app/assets/builds/demo-pairing.js— vanilla ActionCable 클라이언트e2e/scenarios/06-demo-pages.sh— 200 응답 검증
Back-compat 레거시 경로
| 경로 | 동작 |
|---|---|
/demo | /oauth alias |
/demo/start | PKCE state/verifier 생성 → api.1pass.dev/oauth/authorize 302 |
/demo/callback | code → /oauth/token 교환 → /oauth/userinfo |
/demo/mac/install · /demo/mac/notify (POST) | Mac 출시 알림 placeholder |
/demo/pair/:pair_id · /result | QR 핸드오프 iPhone 착륙 / 데스크톱 결과 |
/demo/clip | 레거시, demo.1pass.dev/ 301 (실제 invocation URL 은 api.1pass.dev/demo/clip) |
QR 시나리오 — multi-device 핸드오프
/qr 로드 → DemoPairingSession 생성 (WAITING), cookies.signed[:demo_pair_id] 발급, QR 렌더. iPhone 스캔 → /demo/pair/:pair_id 착륙 → OAuth 라운드트립 → 완료 시 ActionCable 로 데스크톱 자동 갱신.
시퀀스
Desktop (브라우저) Rails 서버 iPhone (logi 앱)
│ │ │
│ GET /qr │ │
│──────────────────────►│ │
│ │ DemoPairingSession.create! │
│ │ (state: WAITING) │
│ HTML + QR + cookie │ │
│◄──────────────────────│ │
│ subscribe Channel │ │
│ (pair_id) │ │
│──────────────────────►│ │
│ confirm_subscription │ │
│◄──────────────────────│ │
│ │ GET /demo/pair/:pair_id │
│ │◄─────────────────────────────│ (QR scan)
│ │ /oauth/authorize 라운드트립 │
│ │◄────────────────────────────►│
│ │ POST /api/v1/demo/pair/ │
│ │ :pair_id/complete │
│ │ (Bearer PAK + userinfo) │
│ │◄─────────────────────────────│
│ │ transition!(WAITING → │
│ │ COMPLETED) │
│ ActionCable.broadcast│ │
│ {state, result_path} │ │
│◄──────────────────────│ │
│ location.replace(...) │ │
│ GET /demo/pair/:id/ │ │
│ result │ │
│──────────────────────►│ │
│ 결과 화면 (userinfo) │ │
│◄──────────────────────│ │만료 / 보안
expires_at = 5.minutes.from_now.DemoPairingChannel#subscribed가expired?+terminal?둘 다 검사.cleanup_expired!가 비-terminal 만료 행을EXPIRED로 이동.cookies.signed[:demo_pair_id]가 채널pair_id와 일치해야 구독 허용 (제3자 엿듣기 차단).- 완료된 페어에 재-POST → atomic
transition!가드 + idempotent → 같은 200 응답, 재방송 없음. client_metadata(client_id / redirect_uri / scope) 가Web::DemoController#show와 정확히 일치해야 함. 불일치 시 iPhone 측 authorize URL 이 invalid_request 로 조용히 깨짐.
상태 상수
정상 경로: WAITING, SCANNED, APPROVED, COMPLETED
종료 상태: COMPLETED, DENIED, EXPIRED (TERMINAL_STATES)검증된 전이:
WAITING / SCANNED / APPROVED → COMPLETED—Api::V1::DemoPairController#completeatomictransition!WAITING / SCANNED / APPROVED → EXPIRED—cleanup_expired!DENIED전이는 정의되어 있으나 현재 트리거 경로 없음
TERMINAL_STATES 행은 전이/구독 모두 거부됩니다.
Mac 시나리오
Mac 앱 (com.dcodelabs.logi.mac build 24) entitlement: applinks:api.1pass.dev + applinks:open.1pass.dev. OAuth 흐름은 /session/new 의 "Mac 앱에서 열기" 카드가 담당. /mac 페이지 CTA 는 /demo/mac/install placeholder.
데모 intent handler 출시 후: trampoline route (출시 시점 확정) → 앱 설치 시 AASA 매칭 + 자체 PKCE / 미설치 시 /demo/mac/install 다운로드 fallback. OAuth authorize 직링은 PKCE 누락 fallback 이슈로 사용 안 함 (Universal Links 흔한 실수 참조).
CSP 청정 — RP 통합 시 따라할 패턴
데모 layout 은 importmap / Stimulus 를 빼고 정적 <script src> 하나만 로드 → script-src 'self' https: (unsafe-inline 없음) 환경에서 nonce 없이 동작.
erb
<%# app/views/layouts/demo.html.erb (head 발췌) %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
<%= javascript_include_tag "demo-pairing", defer: true %>ActionCable 동작은 @rails/actioncable 없이 vanilla JS 약 100 줄:
js
// app/assets/builds/demo-pairing.js (subscribe 발췌)
var identifier = JSON.stringify({
channel: "DemoPairingChannel",
pair_id: pairId
});
function connect() {
ws = new WebSocket(cableUrl()); // wss:// 또는 ws://
ws.onopen = function () {
ws.send(JSON.stringify({ command: "subscribe", identifier: identifier }));
};
ws.onmessage = function (event) {
var data = JSON.parse(event.data);
if (data.type === "confirm_subscription") {
attempts = 0; // healthy → reset backoff
return;
}
if (data.identifier === identifier && data.message) {
if (data.message.state === "completed" && data.message.result_path) {
done = true;
ws.close();
window.location.replace(data.message.result_path);
}
}
};
ws.onclose = function () {
if (!done) scheduleReconnect(); // exponential backoff, max 10 retries
};
}RP 체크리스트
- inline
<script>금지 — 외부<script src>한 개로. - importmap 의존성 제거 —
<script type="importmap">도 inline 으로 차단됨. - DOM 데이터는
data-*속성으로 —data-demo-pairing-pair-id,data-demo-pairing-result-path를 ERB 에서 박고 JS 가getAttribute로 읽기. - WebSocket 직접 핸들링 —
wss://your-domain/cable에 붙으면 라이브러리 없이 ActionCable 채널 수신 가능.
콜백 타임아웃
/demo/start → /demo/callback 유효 시간: 30분 (첫 앱 설치 + Apple SSO + 인앱 escape + TestFlight 등 외부 동선 대응). PKCE state 는 1회용이라 공격면 증가 미미.
에러 분기
| 상태 | 표시 |
|---|---|
started_at 살아있고 30분 이내, state 일치 | 정상 토큰 교환 |
| 30분 초과 | "데모 세션이 만료되었습니다" |
| 세션 자체 없음 (다른 브라우저 콜백) | "데모 세션을 찾을 수 없습니다" |
| 세션 있고 state 불일치 | "state mismatch — 보안상의 이유로 인증을 중단했습니다" |
| 정상 처리 후 같은 콜백 URL 재클릭 | "데모 세션을 찾을 수 없습니다" (1회차에서 clear) |
Double-callback 안전성
앱 → Safari 핸드오프 / prefetch / 사용자가 새 탭으로 콜백 열기 시 동일 (state, code) 페어 콜백 중복 발생. 컨트롤러는 성공 또는 명확한 실패 후에만 session clear (demo_controller.rb#callback) — 첫 호출이 state 를 미리 소비해 두 번째가 mismatch 로 보이는 함정 회피.
회귀 spec: spec/requests/web/demo_spec.rb ("timeout boundary" / "double-call safety").
다음 단계
- Quickstart (5분) — curl 만으로 OAuth 라운드트립
- Universal Links 통합 가이드 — Apple OS 핸드오프 패턴
- OAuth Authorization Code Flow —
/oauth시나리오 표준 시퀀스 - 📱 모바일 트랙 · 🌐 웹 트랙