feat(oauth): add OAuth CSRF nonce + /confirm handshake (Ref #9546)#141
Merged
Conversation
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…ef #9546) Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…tMenu (Ref #9546) Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
size-limit report 📦
|
Replaces the unused tsdx <Thing /> scaffold with a config-driven Vault playground that surfaces nonce-storage, postMessage events, and onConnectionChange callbacks live — so a developer with a staging JWT can exercise the new OAuth CSRF handshake without external tooling. Also bumps the example to parcel 2 (parcel 1 fails on current Node with "Invalid Version: undefined" from babel-preset-env) and ignores .parcel-cache. Co-Authored-By: Claude <noreply@anthropic.com>
Parcel 1 won't run on current Node, and parcel 2 produced multiple React copies in the dev bundle (host React 17 + nested React 16 from @apideck/wayfinder), which fired "Invalid hook call" and rendered nothing. Vite's resolve.dedupe + alias pin both example and the consumed react-vault.esm.js to the parent's single React 17, so the page actually mounts. wayfinder still ships its own bundled React for its field-mapping component, but that surface isn't reached on the OAuth test path. Co-Authored-By: Claude <noreply@anthropic.com>
Drops the homemade testing harness UI in favour of the single component the example exists to demonstrate. JWT and connector are configured via example/.env (see .env.example). Also pins example's @types/react to match the parent (17.0.39) so the forwardRef/Key JSX type clash doesn't fire, and updates tsconfig to es2020/esnext so import.meta resolves. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Mirrors the consumer-side import documented in README.md so the playground renders the Vault modal with full Tailwind styling, matching how a real package consumer integrates @apideck/react-vault. Co-Authored-By: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related Issue: GH-9546
Summary
30812f60and vaulta498117d. Without this PR, allowlisted accounts in unify never complete the new two-step authorize → confirm flow when authorizing through@apideck/react-vault.oauth_complete/oauth_errorpostMessages from the vault callback page, andPOSTs to the new/{api}/{service}/confirmendpoint to mark the credential trustworthy.child.closedpoll path. No public API changes; no consumer-side migration needed.Why this change
unify GH-9546 splits OAuth completion into two steps for accounts on the
oauthCsrfallowlist:GET /vault/authorize?...&nonce=…— opener generates a one-shot nonce (sessionStorage-bound, per service id).…#nonce=…&confirm_token=…&service_id=…. The vault callback page postMessages the opener.POSTs/vault/connections/{api}/{service}/confirmwith theconfirm_token. unify only markscredentials.confirmed = trueafter this call lands.Until
/confirmis called,connection.healthis'pending_confirmation'andstatestays at'authorized'(not'callable'). vault and unify are already deployed; vault-core was the missing client piece.What changed
New files
src/utils/oauthCsrf.ts—generateAndStoreNonce,verifyAndClearNonce(one-shot; clears regardless of match, mirroringvault/src/utils/oauthCsrf.ts),clearNonce,callConfirmEndpoint.src/types/OAuthCsrf.ts—OAuthCompleteMessage,OAuthErrorMessage,OAuthPostMessage,ConfirmResponse.Modified
src/types/Connection.ts— addsConnectionHealthunion ('ok' | 'pending_refresh' | 'needs_auth' | 'needs_consent' | 'revoked' | 'missing_settings' | 'pending_confirmation') and optionalConnection.healthfield.src/components/StatusBadge.tsx— yellow "Pending confirmation" badge whenhealth === 'pending_confirmation'. Takes precedence over thestate-based switch but yields toconsent_statechecks (matches unify's priority order).src/components/AuthorizeButton.tsx— popup branch now generates a nonce, appends&nonce=to the authorize URL, registers amessagelistener (with type + serviceId guards),POSTs/confirmon success, and adds a 1000 ms grace window afterchild.closedso postMessage handling can finish before the existing fallbackmutateruns. Cleanup is paired in every code path (success, error, popup-closed-no-message, component unmount).src/utils/connectionActions.ts— same listener + grace logic added touseConnectionActions.handleRedirect's popup branch (re-authorize flow). Theclient_credentials/passwordtoken-grant branch is untouched.src/components/TopBar.tsx,src/components/ButtonLayoutMenu.tsx(×2 sites) — append&nonce=to authorize URLs at click time. Revoke URLs are unchanged.src/utils/i18n.ts— translations forPending confirmation,Could not confirm authorization,Authorization failedin en / nl / fr / de / es.Tests (27 new cases across 4 files)
test/oauth-csrf.test.ts(new, 9 cases) — nonce gen / one-shot verify / clear /callConfirmEndpointURL + headers + body + error.test/status-badge.test.tsx(new, 5 cases) — pending_confirmation rendering, palette, precedence, no regression.test/authorize-button.test.tsx(extended, +7 cases) — nonce-on-URL, oauth_complete → /confirm, oauth_error toast, foreign serviceId ignored, nonce mismatch, child.closed grace, no double-confirm.test/connection-actions.test.tsx(new, 6 cases) — same surface forhandleRedirect.Out of scope (intentional)
event.originverification in the postMessage handler.react-vaultis a library deployed to consumer-controlled origins, andredirect_uriis per-session — no fixed trusted origin exists. The nonce (sessionStorage-bound, per-service-id) is the actual CSRF defense;typeandserviceIdare still filtered for correctness. Documented in the plan's "What We're NOT Doing"./authorize. unify shipped the GET-with-?nonce=design; the earlier abandoned vault-core attempt referenced acallAuthorizeEndpointPOST that no longer exists in unify.thoughts/shared/progress/...status.json.client_credentials/passwordgrant changes — those POST/tokendirectly without a popup; CSRF-irrelevant.Backwards compatibility
oauthCsrfallowlistchild.closedpoll runsmutate. Identical UX to today.@apideck/react-vault(no nonce sent)nonce && csrfEnabledgate fails closed → plain redirect → same fallback. Same as today.oauth_errorpostMessage triggers a toast and refresh;child.closedfallback also runs after 1000 ms grace.Component public API (
<Vault>props,onConnectionChangecallback shape) is unchanged. No consumer code changes required. iframe-vault picks up the fix on nextnpm install.How to verify it
Automated (run from repo root)
yarn tsdx test --no-watch— 10 suites, 59 tests, all passing (was 32 onmain).yarn tsdx build— succeeds, types clean.yarn tsdx lint— broken at project level, unrelated to this PR.eslint-plugin-prettieris missing fromnode_modules(referenced by tsdx's eslint config). Pre-existing. Recommend a follow-up to either reinstall the dependency or remove the prettier plugin from the lint config.Manual (requires staging credentials — left for reviewer)
Allowlisted account on staging (e.g.,
22222222):/vault/authorize/...with&nonce=….POST /vault/connections/{api}/{service}/confirmwith{"confirm_token":...}body, responsedata.confirmed: true.apideck_oauth_nonce_{serviceId}is gone after success.Non-allowlisted account:
/confirmcall. No regression vs current production.Error path (allowlisted):
oauth_error→ toast appears with the error description → connection stays in previous state.Changelog entry
Suggested commit-message / PR title
feat(oauth): add OAuth CSRF nonce + /confirm handshake (Ref #9546)References
thoughts/shared/plans/2026-05-05-GH-9546-csrf-fix-vault-core.mdthoughts/shared/research/2026-05-05-GH-9546-csrf-fix-vault-core.md30812f60, vaulta498117d