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 itsredirect_uris(only HTTPS orhttp://localhostare 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
- Register a test RP via the CLI or developer console — a
[test]prefix on thenameis recommended (e.g.[test] ainote staging) - Register staging/localhost URLs as
redirect_uris— since redirect_uri is an exact match, get the trailing slash and case exactly right - Use an externally exposed staging endpoint (via ngrok / Cloudflare Tunnel) as the
webhook_url— you cannot register your local machine directly - 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+ plaintextsecret) — 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
stateandcode_challenge→ authorize → callback → exchange at/oauth/token→/oauth/userinforeturns 200 with theaccess_token - [ ] Re-login (same user) — the same
subis returned. Theaudmatches yourclient_idexactly - [ ] 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_deniedin the callback → show the user a friendly message - [ ] Expired access token — calling the API with a token past its
expreturns 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
kidmismatch, 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>+ anX-Logi-Timestampheader) pass verification. For why both formats coexist and verifier examples, see Webhook signature verification - [ ] Replay rejection — PLAN-L uses the
t=value, legacy usesX-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 guarantee —
user.mergedis handled even if it arrives beforeuser.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
kidmust 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.mergedwebhook - [ ] 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_subandmerged_sub
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
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 common | Keep 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 drift | Verify chrony / NTP; use your JWT library's clockTolerance option |
| Webhook verification failure (signature mismatch) | HMAC computed over a value that was parsed and re-serialized | Always use the raw body (details) |
Webhook Content-Type conflict | Rails' parameter_wrapping / Express's body-parser consumed the body | Register raw-body middleware on a route dedicated to the webhook endpoint |
aud mismatch | Routing a token to the wrong backend in a multi-client_id setup | Assert aud == process.env.LOGI_CLIENT_ID |
| Universal Link not working (iOS) | The AASA file is missing the /oauth/authorize paths | Check 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
# .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.comNon-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/tokenand/oauth/revokeresponses
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:
RSpec.describe "krx_listing RP integration", type: :request do
# ...
end2. 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:
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 } ])
endJWKS 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):
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 guardspec/requests/oauth/refresh_token_rotation_spec.rb— refresh rotation + reuse detectionspec/requests/oauth/redirect_uri_strictness_spec.rb— exact-match + prefix-trap / fragment / userinfo-injection rejection