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
Run the early stage of wiring RP code on your development machine against the local mock IdP below, and do the final pre-launch verification with a test RP on the production IdP. The mock imitates the server contract; it is not the server.
Local mock IdP (logi dev mock)
An in-memory OIDC provider that ships with the CLI. It runs authorize → token → userinfo → refresh → revoke without an account or network access.
logi dev mock -r http://localhost:3000/auth/callback --port 9400
# add --client-secret <value> to run it as a confidential client- The issuer is
http://127.0.0.1:<port>. The startup banner prints the issuer, client_id, redirect_uri and the user'ssub. Without--porta free port is picked, so pin the port if your RP config hard-codes it. - One client and one user are pre-registered, and consent is automatic.
--client-id(defaultmock-client),--redirect-uri(repeatable, required),--client-secret(given:client_secret_basic/client_secret_post; omitted: public PKCE),--email. - Same as the server: PKCE
S256required, exact redirect_uri match (a mismatch returns 400 without redirecting), scope required,state/noncereflected, one-time codes, refresh rotation (reusing a rotated refresh token revokes the whole chain),Cache-Control: no-store,id_tokenonly with theopenidscope, userinfo and refresh rejected after revoke. - Different from the server: codes live 60 seconds (server: 10 minutes), no refresh rotation grace, no
amr/acr, no custom scopes, device flow, introspection or DCR. Discovery advertises only the endpoints the mock implements. - Keys, codes and tokens live only in process memory and disappear when it exits.
Supported environments — only RP servers and web RPs running on the development machine.
- The mock binds to
127.0.0.1only and rejects with 400 any request whoseHostheader is not127.0.0.1:<port>orlocalhost:<port>(DNS rebinding defense). - Browser CORS follows the server's scope:
/.well-known/*accepts any origin,/oauth/*only allowlisted origins. On the server the allowlist is the app'sallowed_origins; on the mock it is the origins of the registered redirect_uris. - On a mobile device
127.0.0.1is the device itself. The Android emulator's10.0.2.2fails the Host check and the issuer comparison, so forward withadb reverse tcp:<port> tcp:<port>(untested). Physical devices are not supported. - Point the SDK's issuer at the mock. Android
LogiAuthConfig(issuer = ...)supports an issuer setting. Whether the other SDKs support it has not been checked.
Webhook receiver test — sends one event with the same envelope, canonical serialization, signing input and headers as the server outbox.
logi dev mock webhook http://localhost:3000/webhooks/logi --event user.merged
# --event: user.merged | user.grants_revoked | webhook_key.compromisedThe signing secret and kid are printed; put them into your receiver's verification code. Only the current X-Logi-Signature: t=,kid=,v1= format is sent, not the legacy sha256= format.
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. Check your local receiver code, including signature verification, withlogi dev mock webhookfirst - 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 and integration tests (mock IdP) — every commit
- Whole flow: in the CI job, start
logi dev mock -r <callback> --port <fixed port> &, set the RP's issuer tohttp://127.0.0.1:<fixed port>, and run authorize → token → userinfo. Stop the process when done. - JWT verification logic only: 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. To exercise the receiving endpoint too, uselogi dev mock webhook - Refresh / revoke error branches: stub the
/oauth/tokenand/oauth/revokeresponses, or reproduce them with the mock IdP
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