Skip to content

Demo 페이지 둘러보기

demo.1pass.dev 는 1pass IdP 통합 모드 6 종을 직접 눌러볼 수 있는 walking sample. 외부 자료에서 시나리오별로 deep-link 하기 좋습니다. 호스트는 start.1pass.dev 세션 쿠키 누출을 막기 위해 분리되어 있습니다.


시나리오 카탈로그

URL컨트롤러대상 / 한 줄 설명
/Web::Demo::IndexController#show카탈로그 허브 (6 모드 비교)
/oauthWeb::Demo::OauthController#show데스크톱 OAuth 2.0 + PKCE 표준 흐름
/qrWeb::Demo::QrController#show데스크톱 ↔ iPhone QR 핸드오프 (DemoPairingSession + ActionCable)
/app-clipWeb::Demo::AppClipController#showiOS App Clip (앱 미설치 사용자, .Clip 번들, AASA appclips.apps)
/iosWeb::Demo::IosController#showiOS 네이티브 (앱 설치 후, Universal Link + Swift SDK)
/androidWeb::Demo::AndroidController#showAndroid (Intent setPackage first-try + Custom Tabs fallback)
/macWeb::Demo::MacController#showmacOS (.mac 번들, Universal Link + 키체인 PKCE)

각 시나리오 페이지는 narrative + 기술 흐름 box + CTA 구조의 읽기 전용 랜딩. CTA 가 /demo/* 백엔드 액션을 호출해 실제 PKCE 라운드트립을 시작합니다.

시나리오 구분 포인트

  • /app-clip vs /ios: App Clip = 앱 미설치 사용자용 (QR/NFC 마케팅 면), /ios = 앱 설치 사용자 RP 내부 인증 (Swift SDK 만 붙이면 끝).
  • /ios first-try 패턴: UIApplication.open(URL:, options: [.universalLinksOnly: true]) → false 반환 시 App Clip/Safari fallback.
  • /android: Intent(ACTION_VIEW, uri).setPackage("com.dcodelabs.logi")ActivityNotFoundException catch → CustomTabsIntent fallback. 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_HOST env 로 staging override 가능.

라우트 매핑

config/routes.rbdemo_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 layout
  • app/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/startPKCE state/verifier 생성 → api.1pass.dev/oauth/authorize 302
/demo/callbackcode → /oauth/token 교환 → /oauth/userinfo
/demo/mac/install · /demo/mac/notify (POST)Mac 출시 알림 placeholder
/demo/pair/:pair_id · /resultQR 핸드오프 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#subscribedexpired? + 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 → COMPLETEDApi::V1::DemoPairController#complete atomic transition!
  • WAITING / SCANNED / APPROVED → EXPIREDcleanup_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 체크리스트

  1. inline <script> 금지 — 외부 <script src> 한 개로.
  2. importmap 의존성 제거<script type="importmap"> 도 inline 으로 차단됨.
  3. DOM 데이터는 data-* 속성으로data-demo-pairing-pair-id, data-demo-pairing-result-path 를 ERB 에서 박고 JS 가 getAttribute 로 읽기.
  4. 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").


다음 단계

MIT License · Identity가 제품의 신뢰를 만듭니다.