Skip to content

RP integration testing guide

An operational guide for verifying that the OAuth flow, plus webhooks and merge, all work before a newly integrated RP goes to production. Not marketing copy — a checklist at the "skip this before launch and you'll get woken up at 3 a.m." level.

Test environment options

logi currently does not run a separate sandbox host. All integration verification is done against the production endpoint (https://api.1pass.dev) using a dedicated test RP registration.

  • Verify by registering a separate test OauthApplication on the production IdP — isolated from production traffic
  • A test RP can register private URLs like localhost, *.ngrok-free.app, or an internal staging domain as its redirect_uris (only HTTPS or http://localhost are allowed)
  • Reproduce irreversible operations — payment, account deletion, suspicious-login lockout — with a test-RP-only account

No self-hosted dev mode

1pass does not provide a local IdP you can spin up with docker-compose up. Even for standalone RP integration work, always use a test RP on the production IdP.

Issuing test credentials

  1. Register a test RP via the CLI or developer console — a [test] prefix on the name is recommended (e.g. [test] ainote staging)
  2. Register staging/localhost URLs as redirect_uris — since redirect_uri is an exact match, get the trailing slash and case exactly right
  3. Use an externally exposed staging endpoint (via ngrok / Cloudflare Tunnel) as the webhook_url — you cannot register your local machine directly
  4. The issued values:
    • client_id (logi_xxx...)
    • client_secret (confidential clients only — shown once, store it in the staging secret manager immediately)
    • the initial webhook signing key (kid + plaintext secret) — likewise shown once

Always keep the test RP and the production RP on different client_ids / webhook secrets. Sharing them means a key rotated in staging affects production too.

Per-flow verification checklist

OAuth Authorization Code + PKCE

Run at least the following cases by hand right before production:

  • [ ] First login — generate state and code_challenge → authorize → callback → exchange at /oauth/token/oauth/userinfo returns 200 with the access_token
  • [ ] Re-login (same user) — the same sub is returned. The aud matches your client_id exactly
  • [ ] Scope expansion request — the consent screen reappears when you request a broader scope than the existing consent
  • [ ] User denial — deny on the authorize screen → handle error=access_denied in the callback → show the user a friendly message
  • [ ] Expired access token — calling the API with a token past its exp returns 401, and it recovers after a refresh
  • [ ] Wrong aud — sending another RP's token to your backend must always return 401 (regression guard against a missing check)
  • [ ] Force-refresh the JWKS cache — on a kid mismatch, it force-refetches once and re-verifies (JWKS cache policy)

scripts/verify-rp.sh (at the repo root) non-destructively checks, in one pass, the /login exposure, the authorize URL parameters, code_challenge_method=S256, and AASA matching from the list above. Run it at least once before a PR.

Webhook receipt

  • [ ] Signature verification — both formats — both PLAN-L (t=<ts>,kid=<kid>,v1=<hex>) and legacy (sha256=<hex> + an X-Logi-Timestamp header) pass verification. For why both formats coexist and verifier examples, see Webhook signature verification
  • [ ] Replay rejection — PLAN-L uses the t= value, legacy uses X-Logi-Timestamp; reject if it's more than ±5 minutes from the current time
  • [ ] Idempotency — dedup on X-Logi-Event-Id (event_id). If the same ID arrives twice, the second one responds 200 only and ignores the side effects
  • [ ] No ordering guaranteeuser.merged is handled even if it arrives before user.created (the logi outbox is best-effort ordering and does not guarantee strict ordering)
  • [ ] Both keys coexist during the grace window — right after a key rotation, both the new and old kid must pass verification
  • [ ] Webhook timeout — if you don't respond 200 within 10 seconds, logi retries — without idempotency you'll process duplicates

Account Merge

For detailed scenarios, see the Account Merge overview and Merge Idempotency.

  • [ ] T2 (cross-provider, same email) — sign up via Apple SSO → re-login via Google SSO with the same email → an automatic merge happens, followed by a user.merged webhook
  • [ ] T3 (OTP-based) — tie two anonymous accounts to the same person via email OTP
  • [ ] 12.3 session_token-based merge — map an active RP-side user to the logi survivor using the RP-side session
  • [ ] T1 (device-link) needs a real device pair and is hard to automate → manual verification only is recommended
  • [ ] After every merge, the RP correctly resolves canonical_user_ids by survivor_canonical_sub and merged_sub

Common pitfalls

SymptomCauseFix
invalid_grant (token exchange)redirect_uri differs from the registered value by one character (trailing /, scheme, case)Compare the registered URI and the URI in code with a hex dump
invalid_grant (code_verifier_mismatch)The challenge ↔ verifier session was lost. A cookie SameSite=Strict + cross-site redirect conflict is commonKeep the challenge in a server-side session or a signed state
JWT exp verification failure (right after a valid issue)Server clock skew >60s — especially Docker host clock driftVerify chrony / NTP; use your JWT library's clockTolerance option
Webhook verification failure (signature mismatch)HMAC computed over a value that was parsed and re-serializedAlways use the raw body (details)
Webhook Content-Type conflictRails' parameter_wrapping / Express's body-parser consumed the bodyRegister raw-body middleware on a route dedicated to the webhook endpoint
aud mismatchRouting a token to the wrong backend in a multi-client_id setupAssert aud == process.env.LOGI_CLIENT_ID
Universal Link not working (iOS)The AASA file is missing the /oauth/authorize pathsCheck curl https://api.1pass.dev/.well-known/apple-app-site-association

CI integration

It's safest to run logi integration tests on two tracks.

1) Smoke test (real IdP calls) — per PR or after deploy

yaml
# .github/workflows/logi-verify.yml
name: logi RP verify
on: [pull_request, deployment_status]
jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run RP verification
        run: ./scripts/verify-rp.sh https://staging.example.com

Non-destructive — it only does a GET of the /login page and an AASA fetch. It checks that the authorize URL has client_id and code_challenge_method=S256 baked in.

2) Unit tests (mock IdP) — every commit

  • JWT verification logic: issue a token with a test RSA key pair, stub the JWKS response with nock / WebMock, then verify
  • Webhook verification: a fixture test that the expected signature matches for a known (secret, body, ts) combination
  • Refresh / revoke: stub the /oauth/token and /oauth/revoke responses

Hitting the real IdP on every commit trips the rate limit — never automate irreversible operations like secret rotation in CI.


logi internals — RP integration spec patterns (for contributors to the logi codebase)

When adding an RP-integration regression spec to the logi server itself:

1. Directory

Putting it in spec/integrations/ means RSpec's type auto-matching does not apply. Specify it explicitly:

ruby
RSpec.describe "krx_listing RP integration", type: :request do
  # ...
end

2. Oauth::KeyStore stub (runs without RAILS_MASTER_KEY)

JWT issuance needs oauth_jwt.keys from credentials. To pass deterministically in CI / locally without a master key, generate an RSA key pair and stub:

ruby
before do
  rsa = OpenSSL::PKey::RSA.generate(2048)
  allow(Oauth::KeyStore).to receive(:active_kid).and_return("test-kid")
  allow(Oauth::KeyStore).to receive(:private_key).and_return(rsa)
  allow(Oauth::KeyStore).to receive(:public_keys)
    .and_return([ { kid: "test-kid", key: rsa.public_key } ])
end

JWKS exposure correctness itself is owned by spec/requests/oauth/jwks_spec.rb — the integration spec verifies only the token-issuance flow.

3. PKCE test vector

Use the RFC 7636 §B.1.1 standard vector (consistent with the other oauth specs):

ruby
let(:verifier)  { "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" }
let(:challenge) { "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }

4. Avoiding the app name collision

let(:app) collides with the Rack app helper and dies with NoMethodError: undefined method 'call' for OauthApplication. Use a different name like let(:rp_app).

5. Reference specs

  • spec/integrations/krx_listing_rp_integration_spec.rb — Web↔Web, App↔Web, pairwise-sub isolation, and a post-login fallback non-hijack regression guard
  • spec/requests/oauth/refresh_token_rotation_spec.rb — refresh rotation + reuse detection
  • spec/requests/oauth/redirect_uri_strictness_spec.rb — exact-match + prefix-trap / fragment / userinfo-injection rejection

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