Skip to content

feat: SDKCore: implement startLogin() - authorization code grant start (ENG-4786)#203

Draft
mrudatsprint wants to merge 9 commits into
miker/eng-4784/central-coordinatorfrom
miker/eng-4786/code-grant-start
Draft

feat: SDKCore: implement startLogin() - authorization code grant start (ENG-4786)#203
mrudatsprint wants to merge 9 commits into
miker/eng-4784/central-coordinatorfrom
miker/eng-4786/code-grant-start

Conversation

@mrudatsprint

@mrudatsprint mrudatsprint commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Issue:

Description:

The start of implementing the Authorization Code Grant with PKCE when DPoP mode is enabled:

  • The key pair is created.
  • The JSON Web Key thumbprint is created.
  • Invoke /oauth2/authorize appending dpop_jkt and PKCE code challenge

…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
mrudatsprint changed the base branch from main to miker/eng-4784/central-coordinator July 18, 2026 21:18
@mrudatsprint mrudatsprint changed the title Miker/eng 4786/code grant start feat: SDKCore: implement startLogin() - authorization code grant start (ENG-4786) Jul 18, 2026
…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.
FusionAuth (as the Authorization Server) never issues a use_dpop_nonce
challenge itself — per FusionAuth's DPoP docs, nonce enforcement is a
Resource Server responsibility implemented by your own APIs, not something
FusionAuth's own endpoints (e.g. /oauth2/userinfo) do. This is why the
existing T2-3 test can only assert structurally ('either outcome is a pass')
against a real FusionAuth instance.

T2-4 adds a self-contained, deterministic test that mocks globalThis.fetch
to simulate a Resource Server 401 response with a use_dpop_nonce challenge
(WWW-Authenticate + DPoP-Nonce headers), then verifies:
- exactly one retry occurs (not zero, not more than one)
- the first proof has no nonce claim
- the retried proof carries the exact server-issued nonce claim
- both proofs target the same htu/htm

Uses its own fresh DPoPManager (via the existing makeManager() helper) so it
does not depend on shared state/order from the Tier 1 tests, and requires no
live FusionAuth instance.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements the initial SDKCore-side wiring for starting an OAuth2 Authorization Code + PKCE flow when DPoP mode is enabled, including generating/persisting the PKCE verifier and redirecting directly to FusionAuth’s /oauth2/authorize with dpop_jkt and PKCE parameters. The PR also updates redirect-state storage format and adjusts unit/e2e tests accordingly across the supported framework SDKs.

Changes:

  • Add SDKCore.startLogin() DPoP-mode path: generate/load DPoP keypair, compute dpop_jkt, generate PKCE verifier/challenge, persist verifier, and redirect to /oauth2/authorize.
  • Introduce shared PKCE utilities (Pkce) with RFC test vectors and update DPoP e2e smoke tests to use them.
  • Update RedirectHelper localStorage format to include an (optional) PKCE verifier and add unit tests; adjust React/Vue/Angular tests for the new format.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/sdk-vue/src/createFusionAuth/createFusionAuth.test.ts Updates redirect-value test fixture to new nonce:verifier:state format.
packages/sdk-react/src/components/providers/FusionAuthProvider.test.tsx Updates redirect-value format and tightens onRedirect expectation.
packages/sdk-angular/projects/fusionauth-angular-sdk/src/lib/fusion-auth.service.spec.ts Updates redirect-value test fixture to new format.
packages/core/src/SDKCore/SDKCore.ts Implements DPoP-mode startLogin() and routes isLoggedIn through DPoPManager when enabled.
packages/core/src/SDKCore/SDKCore.test.ts Adds/updates tests for DPoP-mode startLogin() URL shape + verifier persistence; adds jsdom annotation rationale.
packages/core/src/RedirectHelper/RedirectHelper.ts Extends redirect marker storage format to include PKCE verifier; adds getCodeVerifier(); updates state parsing.
packages/core/src/RedirectHelper/RedirectHelper.test.ts Adds unit coverage for new redirect storage format and verifier retrieval.
packages/core/src/Pkce/Pkce.ts Adds PKCE verifier/challenge generation utilities.
packages/core/src/Pkce/Pkce.test.ts Adds RFC 7636 test-vector coverage for PKCE implementation (node env).
packages/core/src/Pkce/index.ts Exports PKCE utilities from package barrel.
packages/core/src/index.ts Re-exports Pkce from core package root.
e2e/tests/dpop-smoke.test.ts Integrates SDKCore.startLogin() into Tier 0 smoke tests and refactors existing tiers to use shared PKCE utilities.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/core/src/SDKCore/SDKCore.ts Outdated
Comment on lines +10 to +12
* - `state` — optional caller-supplied OAuth2 state value. May contain
* colons; retrieved by joining all segments after index 1
* (skipping the verifier segment).
Comment on lines +82 to +86
// Format: randomNonce:codeVerifier:state
// Skip the nonce segment and the codeVerifier segment; join remainder with
// ':' to correctly reconstruct state values that themselves contain colons.
const [, , ...stateSegments] = redirectValue.split(':');
return stateSegments.join(':') || undefined;
…ENG-4786)

Reverts SDKCore.startLogin()'s signature from 'async ... Promise<void>' back
to plain 'void', matching the public SDKContext/framework-wrapper types
exactly (SDKContext.ts, FusionAuthProviderContext.ts, Vue's FusionAuth<T>,
Angular's SDKContext.ts all still declare startLogin: (state?) => void).

Although tsc --noEmit already reported zero errors thanks to TypeScript's
void-returning-function compatibility rule, the underlying concern was real:
none of the three framework wrappers (React's useRedirecting, Vue's login(),
Angular's startLogin()) awaited or caught the promise, so a DPoP async
failure (e.g. crypto.subtle unavailable, IndexedDB blocked) would surface as
an unhandled promise rejection.

- SDKCore.ts: startLogin() is synchronous again. In DPoP mode it fires a new
  private async startDpopLogin() and catches failures via the new optional
  SDKConfig.onLoginFailure callback (falls back to console.error), following
  the existing onAutoRefreshFailure convention. Cookie mode is unchanged.
- SDKConfig.ts: add onLoginFailure?: (error: Error) => void.
- SDKCore.test.ts: DPoP startLogin() tests now call startLogin() without
  awaiting it and use vi.waitFor() to wait for window.location.assign
  before asserting. Added two new tests covering onLoginFailure and the
  console.error fallback.
- e2e/tests/dpop-smoke.test.ts: added a createAssignWaiter() helper (a
  deferred promise resolved when window.location.assign is called) and
  reworked T0-1/T0-2/T0-3 to use it instead of awaiting startLogin()
  directly. T0-3 now explicitly waits for core1's redirect before swapping
  IndexedDB for core2, preserving the original sequential-completion
  guarantee that awaiting startLogin() used to provide implicitly.

No changes needed to SDKContext.ts, FusionAuthProviderContext.ts, Vue's
types, Angular's types/service, or any framework wrapper implementation —
zero blast radius outside packages/core, as intended.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants