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
| URL | Controller | Target / one-line description |
|---|---|---|
/ | Web::Demo::IndexController#show | Catalog hub (comparing all 6 modes) |
/oauth | Web::Demo::OauthController#show | Desktop OAuth 2.0 + PKCE standard flow |
/qr | Web::Demo::QrController#show | Desktop ↔ iPhone QR handoff (DemoPairingSession + ActionCable) |
/app-clip | Web::Demo::AppClipController#show | iOS App Clip (users without the app installed, .Clip bundle, AASA appclips.apps) |
/ios | Web::Demo::IosController#show | iOS native (after the app is installed, Universal Link + Swift SDK) |
/android | Web::Demo::AndroidController#show | Android (Intent setPackage first-try + Custom Tabs fallback) |
/mac | Web::Demo::MacController#show | macOS (.mac bundle, Universal Link + Keychain PKCE) |
/agent-approval | Web::Demo::AgentApprovalController#show | Agent 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-clipvs/ios: App Clip is for users without the app installed (the QR/NFC marketing surface);/iosis in-RP authentication for users who have the app installed (just attach the Swift SDK and you're done)./iosfirst-try pattern:UIApplication.open(URL:, options: [.universalLinksOnly: true])→ on afalsereturn, fall back to App Clip/Safari./android:Intent(ACTION_VIEW, uri).setPackage("com.dcodelabs.logi")→ catchActivityNotFoundException→ fall back toCustomTabsIntent. 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 todemo.1pass.dev/(preventing a dead link). You can override it for staging with theLOGI_API_HOSTenv var.
Route mapping
The demo_host_constraint block in config/routes.rb:
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
endKey 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/*actionsapp/controllers/web/demo_pair_controller.rb— desktop-side pairingapp/controllers/api/v1/demo_pair_controller.rb— iPhone-side completion endpointapp/views/layouts/demo.html.erb— CSP-clean layout without importmapapp/models/demo_pairing_session.rb— pairing state machineapp/channels/demo_pairing_channel.rb— desktop ActionCable subscriptionapp/assets/builds/demo-pairing.js— vanilla ActionCable cliente2e/scenarios/06-demo-pages.sh— 200-response check
Back-compat legacy paths
| Path | Behavior |
|---|---|
/demo | Alias for /oauth |
/demo/start | Generate PKCE state/verifier → 302 to api.1pass.dev/oauth/authorize |
/demo/callback | Exchange code at /oauth/token → /oauth/userinfo |
/demo/mac/install · /demo/mac/notify (POST) | Mac launch-notification placeholder |
/demo/pair/:pair_id · /result | QR handoff iPhone landing / desktop result |
/demo/clip | Legacy, 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#subscribedchecks bothexpired?andterminal?.cleanup_expired!moves non-terminal expired rows toEXPIRED.cookies.signed[:demo_pair_id]must match the channelpair_idfor 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 matchWeb::DemoController#show. On a mismatch, the iPhone-side authorize URL silently breaks withinvalid_request.
State constants
Happy path: WAITING, SCANNED, APPROVED, COMPLETED
Terminal states: COMPLETED, DENIED, EXPIRED (TERMINAL_STATES)Verified transitions:
WAITING / SCANNED / APPROVED → COMPLETED—Api::V1::DemoPairController#complete, atomictransition!WAITING / SCANNED / APPROVED → EXPIRED—cleanup_expired!- The
DENIEDtransition 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).
<%# 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:
// 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
- No inline
<script>— use a single external<script src>. - Remove importmap dependencies —
<script type="importmap">is also blocked as inline. - Pass DOM data via
data-*attributes — bakedata-demo-pairing-pair-idanddata-demo-pairing-result-pathinto the ERB and have the JS read them withgetAttribute. - Handle the WebSocket directly — connect to
wss://your-domain/cableand 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
| State | Display |
|---|---|
started_at alive and within 30 minutes, state matches | Normal 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
- Quickstart (5 minutes) — an OAuth round trip with curl alone
- Universal Links integration guide — Apple OS handoff patterns
- OAuth Authorization Code Flow — the standard sequence for the
/oauthscenario - 📱 Mobile track · 🌐 Web track