Skip to content

Demo Page Walkthrough

demo.1pass.dev is a walking sample where you can click through all six 1pass IdP integration modes hands-on. It's ideal for deep-linking to specific scenarios from external material. The host is kept separate to prevent start.1pass.dev session-cookie leakage.


Scenario catalog

URLControllerTarget / one-line description
/Web::Demo::IndexController#showCatalog hub (comparing all 6 modes)
/oauthWeb::Demo::OauthController#showDesktop OAuth 2.0 + PKCE standard flow
/qrWeb::Demo::QrController#showDesktop ↔ iPhone QR handoff (DemoPairingSession + ActionCable)
/app-clipWeb::Demo::AppClipController#showiOS App Clip (users without the app installed, .Clip bundle, AASA appclips.apps)
/iosWeb::Demo::IosController#showiOS native (after the app is installed, Universal Link + Swift SDK)
/androidWeb::Demo::AndroidController#showAndroid (Intent setPackage first-try + Custom Tabs fallback)
/macWeb::Demo::MacController#showmacOS (.mac bundle, Universal Link + Keychain PKCE)
/agent-approvalWeb::Demo::AgentApprovalController#showAgent Approval Gate demo (Agent → auth request → mobile approve/deny)

Each scenario page is a read-only landing page with narrative copy, a technical flow box, and a CTA. The CTA calls a /demo/* backend action to kick off a real PKCE round trip.

Telling the scenarios apart

  • /app-clip vs /ios: App Clip is for users without the app installed (the QR/NFC marketing surface); /ios is in-RP authentication for users who have the app installed (just attach the Swift SDK and you're done).
  • /ios first-try pattern: UIApplication.open(URL:, options: [.universalLinksOnly: true]) → on a false return, fall back to App Clip/Safari.
  • /android: Intent(ACTION_VIEW, uri).setPackage("com.dcodelabs.logi") → catch ActivityNotFoundException → fall back to CustomTabsIntent. There's no mechanism equivalent to the iOS App Clip → when the app isn't installed, guide the user to the Play Store or to web Custom Tabs.
  • App Clip invocation URL: api.1pass.dev/demo/clip (AASA-registered). When a non-iOS device scans the same QR, it 301-redirects to demo.1pass.dev/ (preventing a dead link). You can override it for staging with the LOGI_API_HOST env var.

Route mapping

The demo_host_constraint block in config/routes.rb:

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

  # Agent Approval Gate demo (human-in-the-loop)
  get  "agent-approval"      => "web/demo/agent_approval#show",         as: :demo_agent_approval
  post "agent-approval/send" => "web/demo/agent_approval#send_request", as: :demo_agent_approval_send

  # OAuth back-end actions (PKCE). /demo is an alias for /oauth (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

Key files

  • app/controllers/web/demo/{index,oauth,qr,app_clip,ios,android,mac}_controller.rb — scenario landings (allow_unauthenticated_access, layout "demo")
  • app/controllers/web/demo_controller.rb — back-compat /demo/* actions
  • app/controllers/web/demo_pair_controller.rb — desktop-side pairing
  • app/controllers/api/v1/demo_pair_controller.rb — iPhone-side completion endpoint
  • app/views/layouts/demo.html.erb — CSP-clean layout without importmap
  • app/models/demo_pairing_session.rb — pairing state machine
  • app/channels/demo_pairing_channel.rb — desktop ActionCable subscription
  • app/assets/builds/demo-pairing.js — vanilla ActionCable client
  • e2e/scenarios/06-demo-pages.sh — 200-response check

Back-compat legacy paths

PathBehavior
/demoAlias for /oauth
/demo/startGenerate PKCE state/verifier → 302 to api.1pass.dev/oauth/authorize
/demo/callbackExchange code at /oauth/token/oauth/userinfo
/demo/mac/install · /demo/mac/notify (POST)Mac launch-notification placeholder
/demo/pair/:pair_id · /resultQR handoff iPhone landing / desktop result
/demo/clipLegacy, 301 to demo.1pass.dev/ (the actual invocation URL is api.1pass.dev/demo/clip)

QR scenario — multi-device handoff

Load /qr → create a DemoPairingSession (WAITING), issue cookies.signed[:demo_pair_id], render the QR. iPhone scans → lands on /demo/pair/:pair_id → OAuth round trip → on completion, the desktop refreshes automatically over ActionCable.

Sequence

Desktop (browser)         Rails server                iPhone (logi app)
        │                       │                              │
        │  GET /qr              │                              │
        │──────────────────────►│                              │
        │                       │ DemoPairingSession.create!   │
        │                       │  (state: WAITING)            │
        │  HTML + QR + cookie   │                              │
        │◄──────────────────────│                              │
        │  subscribe Channel    │                              │
        │ (pair_id)             │                              │
        │──────────────────────►│                              │
        │  confirm_subscription │                              │
        │◄──────────────────────│                              │
        │                       │  GET /demo/pair/:pair_id     │
        │                       │◄─────────────────────────────│ (QR scan)
        │                       │  /oauth/authorize round trip │
        │                       │◄────────────────────────────►│
        │                       │  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            │                              │
        │──────────────────────►│                              │
        │  result screen        │                              │
        │  (userinfo)           │                              │
        │◄──────────────────────│                              │

Expiry / security

  • expires_at = 5.minutes.from_now. DemoPairingChannel#subscribed checks both expired? and terminal?. cleanup_expired! moves non-terminal expired rows to EXPIRED.
  • cookies.signed[:demo_pair_id] must match the channel pair_id for the subscription to be allowed (blocking third-party eavesdropping).
  • Re-POSTing to a completed pair → guarded by the atomic transition! and idempotent → returns the same 200, with no re-broadcast.
  • client_metadata (client_id / redirect_uri / scope) must exactly match Web::DemoController#show. On a mismatch, the iPhone-side authorize URL silently breaks with invalid_request.

State constants

Happy path:      WAITING, SCANNED, APPROVED, COMPLETED
Terminal states: COMPLETED, DENIED, EXPIRED  (TERMINAL_STATES)

Verified transitions:

  • WAITING / SCANNED / APPROVED → COMPLETEDApi::V1::DemoPairController#complete, atomic transition!
  • WAITING / SCANNED / APPROVED → EXPIREDcleanup_expired!
  • The DENIED transition is defined but currently has no triggering path

Rows in TERMINAL_STATES are rejected for both transitions and subscriptions.


Mac scenario

The Mac app (com.dcodelabs.logi.mac build 24) entitlement: applinks:api.1pass.dev + applinks:open.1pass.dev. The OAuth flow is handled by the "Open in the Mac app" card on /session/new. The /mac page CTA is a /demo/mac/install placeholder.

After the demo intent handler ships: a trampoline route (finalized at launch) → if the app is installed, AASA matching + its own PKCE / if not installed, a /demo/mac/install download fallback. The direct OAuth authorize link is not used because of the missing-PKCE fallback issue (see Universal Links common mistakes).


Agent Approval scenario — human-in-the-loop

/agent-approval is the Agent Approval Gate walking sample. When an external agent (for example Codex or an LLM workflow) tries to perform a sensitive action on the user's account (changing an ad budget, making a payment, granting permissions, etc.), the IdP sends a push to the user's mobile 1pass app, collects a single approve/deny decision, and notifies the agent of the result.

Pressing the demo page's [Send sample approval] button creates an AgentApprovalRequest through the same Agents::RequestCreator pipeline as production (rate limit / audit / in-flight suppression intact), and a push is fired to the paired demo user's phone. The demo does not poll the browser for the decision result — the actual decision-lookup endpoint (/api/agents/approvals/:id) requires the agent's bearer + signature, so it's intended behavior not to delegate that authority to the browser. The page only shows the guidance "confirm the push on your phone, then refresh." If you need live polling, the recommended pattern is for the RP's own backend to poll with agent authentication and push UI updates to the user.

  • Backend controller: app/controllers/web/demo/agent_approval_controller.rb (show + send_request)
  • idempotency: a visitor IP + per-minute key (demo-#{ip}-#{minute}) blocks a refresh loop from draining the agent quota.
  • seed data: the demo user / agent must be injected via a separate seed; in their absence the controller renders a placeholder.

CSP-clean — the pattern to follow for RP integration

The demo layout drops importmap / Stimulus and loads just a single static <script src> → it works without a nonce in a script-src 'self' https: environment (no unsafe-inline).

erb
<%# app/views/layouts/demo.html.erb (head excerpt) %>
<%= csp_meta_tag %>
<%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
<%= javascript_include_tag "demo-pairing", defer: true %>

The ActionCable behavior is about 100 lines of vanilla JS, without @rails/actioncable:

js
// app/assets/builds/demo-pairing.js (subscribe excerpt)
var identifier = JSON.stringify({
  channel: "DemoPairingChannel",
  pair_id: pairId
});

function connect() {
  ws = new WebSocket(cableUrl()); // wss:// or 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 checklist

  1. No inline <script> — use a single external <script src>.
  2. Remove importmap dependencies<script type="importmap"> is also blocked as inline.
  3. Pass DOM data via data-* attributes — bake data-demo-pairing-pair-id and data-demo-pairing-result-path into the ERB and have the JS read them with getAttribute.
  4. Handle the WebSocket directly — connect to wss://your-domain/cable and you can receive ActionCable channel messages without a library.

Callback timeout

The /demo/start/demo/callback validity window: 30 minutes (to accommodate external journeys like a first app install + Apple SSO + in-app escape + TestFlight). The PKCE state is single-use, so the increase in attack surface is negligible.

Error branches

StateDisplay
started_at alive and within 30 minutes, state matchesNormal token exchange
Over 30 minutes"The demo session has expired"
No session at all (callback from a different browser)"The demo session was not found"
Session exists but state mismatch"state mismatch — authentication was stopped for security reasons"
Re-clicking the same callback URL after a successful run"The demo session was not found" (cleared on the first run)

Double-callback safety

On an app → Safari handoff / prefetch / the user opening the callback in a new tab, the same (state, code) pair callback fires more than once. The controller clears the session only after a success or a clear failure (demo_controller.rb#callback) — avoiding the trap where the first call consumes the state early and the second appears as a mismatch.

Regression specs: spec/requests/web/demo_spec.rb ("timeout boundary" / "double-call safety").


Next steps

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