feat: The Authorization Code Grant can be started - #203
Merged
mrudatsprint merged 51 commits intoJul 24, 2026
Merged
Conversation
- Add `dpop` v2.1.1 runtime dependency and `fake-indexeddb` devDependency to packages/core/package.json - SDKConfig: add optional `useDpop` and `dpopTokenStorage` fields - UrlHelper: add `getAuthorizeUrl(state?, dpopJkt?, codeChallenge?)` targeting FusionAuth /oauth2/authorize directly; update UrlHelperTypes to include response_type, code_challenge, code_challenge_method, dpop_jkt - DPoPStorage: IndexedDB abstraction for ES256 CryptoKeyPair persistence (db: fusionauth-sdk:dpop, store: keypair, keyed by clientId) - DPoPTokenStore: localStorage/memory token storage for DPoP-bound tokens (key: fusionauth-sdk:tokens:<clientId>); includes getAccessToken() and isExpired getter - packages/core/src/DPoP/index.ts re-exports both classes - 54 tests passing (21 DPoPTokenStore, 6 DPoPStorage, 16 UrlHelper, 7 SDKCore, 4 CookieHelpers)
- Remove 'as any' cast in catch block — reject() accepts unknown directly - Add tests for indexedDB unavailable (SSR/non-browser): all three public methods (getKeyPair, setKeyPair, clearKeyPair) reject with a descriptive error - Add test for indexedDB.open() throwing synchronously (e.g. security policy block)
All three DPoPStorage methods (getKeyPair, setKeyPair, clearKeyPair) now resolve on tx.oncomplete and reject on tx.onerror / tx.onabort. Previously, resolving on req.onsuccess meant the caller was told 'success' before the transaction had fully committed — a transaction abort occurring after the request succeeded (e.g. quota exceeded) would go undetected. Applies the same fix consistently to all three methods, including getKeyPair (readonly, lower risk, but now consistent) and setKeyPair (readwrite, same durability concern as clearKeyPair). Adds a test that aborts a clearKeyPair transaction synchronously inside the request onsuccess handler and verifies the promise rejects and the key pair is still present in IndexedDB.
…84/central-coordinator' of github.com:FusionAuth/fusionauth-javascript-sdk into miker/eng-4784/central-coordinator
…g object - Export DEFAULT_DPOP_DB_NAME, DEFAULT_DPOP_DB_VERSION, DEFAULT_DPOP_STORE_NAME as named constants (no hardcoded magic strings anywhere in the codebase) - Add DPoPStorageConfig interface with clientId (required) and optional dbName, dbVersion, storeName fields — each defaults to the exported constant - Refactor DPoPStorage constructor from positional (clientId: string) to config object, matching the UrlHelperConfig convention in this monorepo - openDb() and all three public methods now reference instance fields (this.dbName, this.dbVersion, this.storeName) instead of module constants - Add tests: defaults apply when no config overrides provided; custom dbName and storeName land data in the right database; two instances with different dbNames but the same clientId do not share keys; dbVersion downgrade produces a clean rejection (VersionError) - Update AGENTS.md: note the config-object constructor convention and the IndexedDB dbVersion must-only-increase constraint
…central-coordinator
…ENG-4782 refactor
…t (ENG-4786) - Add Pkce module (generateCodeVerifier, generateCodeChallenge) with RFC 7636 Appendix B test vector coverage; runs under @vitest-environment node - Extend RedirectHelper to persist code_verifier as a second colon-delimited segment alongside state; add public getCodeVerifier() getter; add test file - SDKCore: construct DPoPManager when config.useDpop is true; startLogin() is now async — DPoP branch calls getOrCreateKeyPair()/getThumbprint() and generates PKCE params then redirects to /oauth2/authorize directly; isLoggedIn delegates to DPoPManager.isLoggedIn in DPoP mode (not app.at_exp cookie) - SDKCore.test.ts: add DPoP-mode describe block with mocked DPoPManager and Pkce (jsdom lacks crypto.subtle); all existing cookie-mode tests unaffected - e2e/dpop-smoke.test.ts: replace local generatePkce() helper with shared Pkce module; add Tier 0 tests exercising SDKCore.startLogin() in DPoP mode end-to-end (no live FusionAuth required for Tier 0) - Export Pkce from packages/core/src/index.ts Note: yarn test:core cannot run in this sandbox environment due to a missing @rollup/rollup-linux-arm64-gnu native binary (arch mismatch); TypeScript compilation (tsc --noEmit) and ESLint/Prettier are clean.
…edirect assertion Without the explicit jsdom annotation, vitest inherits the 'node' environment from DPoPManager.test.ts when the full suite runs, causing 'document is not defined' and 'window is not defined' failures in all SDKCore tests. Also corrects the handlePreRedirect spy assertion: cookie-mode startLogin() passes one argument (state), not two — the codeVerifier arg is only added in DPoP mode.
SDKCore's constructor calls scheduleTokenExpiration() which calls
getAccessTokenExpirationMoment(). In a Node/Playwright process document
doesn't exist, so CookieHelpers catches the ReferenceError and logs
'Error accessing cookies...' to console.error. The tests still pass, but the
stderr noise is confusing.
Fix: extract a shared DPOP_CONFIG constant in the Tier 0 describe block that
includes a no-op cookieAdapter ({ at_exp: () => undefined }). This causes
getAccessTokenExpirationMoment() to take the adapter path and skip
document.cookie entirely, eliminating the noise.
Also fixes T0-1 where the await core.startLogin() call was accidentally
dropped during the previous config refactor.
mrudatsprint
changed the base branch from
main
to
miker/eng-4784/central-coordinator
July 18, 2026 21:18
…ormat RedirectHelper now stores nonce:codeVerifier:state (three colon-delimited segments) instead of the previous nonce:state (two segments). The Angular sdkcore/ directory is generated by 'yarn get-sdk-core' which copies packages/core/src/ verbatim — so in CI the Angular RedirectHelper picks up the updated parser automatically. The test was writing the old two-segment format 'abc123:/welcome-page', which the new parser splits as [nonce='abc123', codeVerifier='/welcome-page', state=''] — returning undefined for state instead of '/welcome-page'. Fix: write 'abc123::/welcome-page' (empty codeVerifier segment, matching cookie mode where no verifier is stored).
…value format
RedirectHelper now stores nonce:codeVerifier:state (three colon-delimited
segments) instead of nonce:state (two segments). Both sdk-vue and sdk-react
import SDKCore directly from @fusionauth-sdk/core (via the @fusionauth-sdk/*
tsconfig path alias), so their tests exercise the live, current
RedirectHelper — same root cause as the earlier Angular fix.
- packages/sdk-vue/src/createFusionAuth/createFusionAuth.test.ts: was seeding
the old 2-segment format ('rAnd0mStR1ng:<state>'), causing the new state
getter to return undefined instead of the expected state value. Fixed to
'rAnd0mStR1ng::<state>' (empty codeVerifier segment).
- packages/sdk-react/src/components/providers/FusionAuthProvider.test.tsx:
had the same stale 2-segment seed, but wasn't caught by CI because the
assertion only checked toHaveBeenCalled() (no argument check). Fixed the
seed format and strengthened the assertion to toHaveBeenCalledWith(stateValue)
to restore real coverage of the callback argument.
… dropping one (PR #202 review) _resolveHeaders() previously returned early with init.headers whenever it was present, silently discarding any headers already set on a Request object passed as input. This contradicted the _doFetch documentation's promise to never drop caller headers. _resolveHeaders() now returns a merged Headers object: init.headers is the base, and Request.headers are layered on top, winning on any conflicting header name.
…mption (PR #202 Copilot review) fetch() previously passed the same input reference to both the initial attempt and the nonce-triggered retry. If input was a Request with a body, the first attempt consumed it, and the retry would throw a 'body already used' error instead of succeeding. - Clone the Request twice up front (before either is read from) so each attempt gets an independent, unconsumed body. Request.clone() safely tees any internal streaming body per spec, so this also covers a Request built with a ReadableStream body. - A raw ReadableStream passed via init.body (not wrapped in a Request) cannot be cloned this way. On retry, this now throws a clear, actionable error instead of letting native fetch throw an opaque one.
…opilot review) generateProof() documented htu as being 'without query/fragment' but passed it through unmodified. fetch() supplies Request.url, which can include a query string, so proofs generated via dpopFetch() could carry an htu that includes query parameters — a subtle interop bug with strict DPoP verifiers. htm was also not normalised to uppercase, which most DPoP verifiers require. Both are now normalised inside generateProof() itself, so this is correct regardless of whether callers go through fetch() or call generateProof() directly with arbitrary casing/query strings.
Base automatically changed from
miker/eng-4784/central-coordinator
to
parent/dpop-in-the-javascript-sdk
July 22, 2026 14:59
wied03
reviewed
Jul 24, 2026
…touched
Per PR review discussion: instead of patching around the ambiguity between
the legacy 2-segment (nonce:state) and current 3-segment
(nonce:codeVerifier:state) colon-delimited formats, eliminate the ambiguity
entirely by using two structurally distinct, non-overlapping formats under
the same fa-sdk-redirect-value key:
- Hosted backend mode (no codeVerifier passed to handlePreRedirect): plain
string `${randomNonce}:${state ?? ''}` — exactly the format every
published SDK version has always written, byte-for-byte unchanged. There
is no 'legacy format' to handle anymore because this format itself never
changed.
- DPoP mode (codeVerifier passed): JSON object { codeVerifier, state }.
JSON.parse deterministically throws on the hosted-backend plain string (it
never starts with '{', and a bare 'nonce:state' string can never be valid
JSON on its own — even an all-digit nonce fails because JSON.parse requires
the entire string to be one valid value, and trailing ':state' content after
a parsed number is rejected). This makes the two formats provably
non-ambiguous, unlike the previous 'count the colons' heuristic which had an
acknowledged edge case (a legacy state value containing its own colon was
indistinguishable from a new-format value with a real codeVerifier).
This also matches the existing convention in DPoPTokenStore, which already
uses JSON.stringify/JSON.parse for its persisted data rather than a
delimited string.
RedirectHelper.ts:
- handlePreRedirect/handlePostRedirect/getCodeVerifier signatures unchanged
(no SDKCore.ts changes needed) — only the internal storage format changed.
- Replaced the private "state" getter with a "parseState(raw)" method that
takes the already-fetched raw value (handlePostRedirect previously read
the value from storage twice per call; now once).
- Removed the now-obsolete 'exactly one colon = legacy' segment-counting
logic from getCodeVerifier() and the old state getter.
RedirectHelper.test.ts:
- Removed the 'legacy 2-segment format backward-compatibility' describe
block (4 tests) — no longer applicable, since hosted backend mode's format
never changes.
- Added a new 'storage format' describe block: hosted backend mode's raw
value is verified to still be a plain non-JSON string; DPoP mode's raw
value is verified to be the expected JSON shape; switching modes on the
same helper instance in either direction doesn't leak data from the
previous format; an empty-string codeVerifier still takes the JSON path
but getCodeVerifier() correctly returns undefined for it.
mrudatsprint
commented
Jul 24, 2026
mrudatsprint
left a comment
Collaborator
Author
There was a problem hiding this comment.
Second self-review. A few comments that are addressing the first PR review.
wied03
reviewed
Jul 24, 2026
…oredValue() Follow-up to PR #203 review discussion about relying on a caught JSON.parse() SyntaxError as the primary discriminator between DPoP mode's JSON format and hosted backend mode's plain string format. Although the performance concern doesn't really apply here (this is called at most once or twice per completed redirect, not a hot path, and modern JS engines no longer deoptimize functions containing try/catch), the underlying code smell was worth addressing: hosted backend mode calls hit the catch branch on 100% of calls, not as a rare/exceptional case. Changes: - handlePreRedirect(): named the write-side mode check `isDpopMode = codeVerifier !== undefined` instead of an inline anonymous condition in the ternary. - Replaced parseState() and the duplicated try/catch logic inside getCodeVerifier() with a single parseStoredValue(raw) method that returns both `codeVerifier` and `state` in one pass. The DPoP-vs-hosted-backend distinction is now expressed in exactly one place in the class, via a cheap, deterministic `raw.startsWith('{')` sniff rather than a caught exception — DPoP mode's JSON values always start with `{`; hosted backend mode's plain `nonce:state` strings never do (the nonce is always hex digits), so this is a provably correct discriminator with no exception construction/throwing on the common hosted-backend path at all. - getCodeVerifier() and handlePostRedirect() now both delegate to parseStoredValue() instead of having their own separate parsing logic. Pure internal refactor — no public API or behavior change. All 18 existing RedirectHelper.test.ts tests pass unchanged, confirming no external behavior was affected.
mrudatsprint
commented
Jul 24, 2026
mrudatsprint
left a comment
Collaborator
Author
There was a problem hiding this comment.
self-review
wied03
approved these changes
Jul 24, 2026
mrudatsprint
merged commit Jul 24, 2026
1da8ebe
into
parent/dpop-in-the-javascript-sdk
4 checks passed
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.
Issue:
Description:
The start of implementing the Authorization Code Grant with PKCE when DPoP mode is enabled:
/oauth2/authorizeappendingdpop_jktand PKCEcode challenge