Repository navigation
feat: redeem the authorization code at the authority it was minted at - #19
Merged
Merged
Conversation
An authorization-code sign-in has two legs and the caller could steer only the first. authorizationUrlRequestOverride puts an authority on the authorize leg, then acquireTokenByCode was called with none, so the confidential client's own configured authority redeemed the code. Entra binds a token's tid to the authority the code was minted at, so a tenant-agnostic authority on the token leg works — which is why a multi-tenanted app has had to set its client authority to /organizations. A single-tenant app registration (signInAudience: AzureADMyOrg) refuses that with AADSTS50194, so every consumer has had to derive its client authority from how many tenants it serves. Carry the overridden authority on the PKCE session, alongside the verifier and original URL, and pass it to acquireTokenByCode. The session key is optional, so a session written by 2.1.0 and read mid-deploy simply has none and the client's own authority stays in force. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three inaccuracies from the first pass: - the login-leg comment said the code "must" be redeemed at the authority it was minted at. It need not — redeeming at a tenant-agnostic authority is exactly the /organizations workaround this change removes the need for. - a test comment justified the absent authority key by claiming cookie-session would otherwise serialise a dead one. JSON.stringify drops undefined-valued keys, so the cookie would be identical either way. The real point is that the session is byte-for-byte what 2.1.0 wrote. - the README drew the wrong conclusion from tid binding, saying both legs "must" name the same authority. Replaced with what carrying the authority buys: the client can stay pinned to its own tenant while sign-ins start in others. Also folds the repeated login/reply setup in both specs into helpers, matching the run() helper style already in copy-session-jwt-to-bearer-header.spec.ts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cuzzlor
requested review from
mderriey and
robdmoore
and
a balanced review from Copilot
September 4, 2026 08:14
There was a problem hiding this comment.
🟡 Changes recommended
The lockfile version remains stale, and one compatibility test does not verify authority-property absence.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Carries the authorization authority across both PKCE authentication legs.
Changes:
- Persists the overridden authority in the PKCE session.
- Reuses it during authorization-code redemption.
- Adds tests, documentation, and a minor-version bump.
File summaries
| File | Description |
|---|---|
src/index.ts |
Propagates authority through the PKCE session. |
src/login-handler.spec.ts |
Tests authority persistence during login. |
src/auth-handler.spec.ts |
Tests authority use during redemption. |
README.md |
Documents behavior and security guidance. |
package.json |
Bumps the release to 2.2.0. |
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Updates the three vulnerable transitive dev dependencies within their existing
semver ranges — fast-uri 3.1.5 -> 3.1.7 (4 high, via better-npm-audit's own
ajv), qs 6.15.3 -> 6.16.0 (2 moderate, via express) and fflate 0.8.2 -> 0.8.3
(1 moderate, via @arethetypeswrong/cli). No runtime dependency is affected;
this package declares only peers.
fflate 0.8.3 breaks @arethetypeswrong/cli 0.18.2 ("Cannot read properties of
undefined (reading 'filename')" while reading the packed tarball), so attw goes
to 0.18.5, which reads it fine.
esbuild keeps its .nsprc exception: tsx pins ~0.27.0, so it cannot reach the
patched >=0.28.1 yet, which is what the exception note already anticipates.
Also syncs package-lock.json to 2.2.0, and strengthens the reply-handler
compatibility assertion. expect.not.objectContaining({ authority:
expect.anything() }) passes on { authority: undefined } because anything()
excludes undefined, so it did not actually prove the key was absent. Capturing
the token request and asserting not.toHaveProperty('authority') does, and
matches how login-handler.spec.ts already asserts the same guarantee.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mderriey
approved these changes
Sep 4, 2026
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.
Implements the handover at https://claude.ai/code/artifact/27ade814-99ef-4653-852f-ae8c8ffe339a
Why
An authorization-code sign-in has two legs, and this library let the caller steer only the first.
authorizationUrlRequestOverrideputs an authority on the authorize leg, so an app can send a visitor to the tenant its URL names. The token leg then calledacquireTokenByCodewith no authority, so the confidential client's own configured authority redeemed the code.Entra binds a token's
tidto the authority the code was minted at, not the one it is redeemed at, so a tenant-agnostic authority on the token leg still finishes a sign-in that started anywhere. That is the workaround in use today: an app serving more than one tenant sets its client authority to/organizations. The cost is that a single-tenant app registration (signInAudience: AzureADMyOrg) refuses/organizationswith AADSTS50194 — so every consumer has to derive its client authority from how many tenants it serves, and keep that derivation in step with the registration's audience. The library knew the right authority already; it just threw it away between the legs.The change
Three edits in
src/index.ts. The authorize leg already writes a session to carry the PKCE verifier and the original URL across the round trip — the authority travels the same way.authority?: stringonPKCEStartedSession.createLoginHandlerresolves the override before writing the session, so the resolved authority can be stored.createAuthHandlerspreads it intotokenRequestwhen the session carries one.The type already allowed it:
authorityis not among the keysAuthorizationCodeRequestomits fromCommonAuthorizationCodeRequest, so it arrives asauthority?: stringviaBaseAuthRequest.Compatibility
Additive — hence 2.2.0. The session key is optional: a session written by 2.1.0 and read by 2.2.0 mid-deploy simply has no authority, and the client's own stays in force. With no override, or one naming no authority, the key is absent rather than
undefined, so the session is byte-for-byte what 2.1.0 wrote.One behavioural change beyond the three edits: because
req.session =now runs after the override resolves, an override that throws leaves the session untouched instead of half-written, and an override that readsreq.sessionnow sees the incoming session rather than the freshly-clobbered PKCE one. Neither is documented behaviour, and both readings are the more sensible of the two.Tests
login-handler.spec.tsgains three cases;auth-handler.spec.tsis new, reaching the reply handler by capturing whatpkceAuthenticationMiddlewareregisters withapp.get.getAuthCodeUrlstill receives itauthoritykey on the sessionacquireTokenByCodereceives itBoth positive cases were confirmed to fail against
main'ssrc/index.tsand pass with the change. The reply-handler compatibility case asserts theauthoritykey is absent, not merely undefined, and was confirmed to fail if the conditional spread is replaced by a plainauthority,.Locally: 12 tests pass, and
npm run audit,npm run build(lint, types, rollup,attw) andprettier --checkall exit 0.Dev-tree audit advisories, cleared here
CI's
Auditstep was failing on seven advisories that predate this branch —package-lock.jsonwas identical tomain, and GitHub reported the same set on the default branch. Since the step runs first and was skipping Lint, Compile, Test and Build behind it, they are cleared here rather than in a follow-up:fast-uribetter-npm-audit→table→ajvqsexpressfflate@arethetypeswrong/cliAll three land within their parents' existing semver ranges, so no direct dependency changed for them. No runtime dependency is affected either way — this package declares only peers.
Two consequences worth flagging:
fflate0.8.3 breaks@arethetypeswrong/cli0.18.2, which fails reading the packed tarball withCannot read properties of undefined (reading 'filename').attwtherefore goes 0.18.2 → 0.18.5, keeping the repo's exact-pin convention.build:6-check-exportspasses again.esbuildkeeps its existing.nsprcexception (1 low).tsxpins~0.27.0, so it cannot reach the patched>=0.28.1— exactly the condition that exception's own note anticipates.package-lock.jsonis also synced to 2.2.0.Manual verification still outstanding
The unit tests prove the authority reaches
acquireTokenByCode. Only Entra proves it is accepted, and that needs a single-tenant registration and two tenants — A hosting it, B a customer:<api>/graphql?tenant=<B>with the client authority pinned to A. Before this change it fails on the token leg; after it, the token comes back withtid= B.These overlap with the manual steps outstanding on graphql-template-esm#10, so they are worth running together once a second tenant with admin consent exists.
Security
The stored value comes from
authorizationUrlRequestOverride, which is the consuming app's own function — never from the request. The README now says so explicitly: an authority taken straight off a query parameter would let a crafted link aim a sign-in at any path underlogin.microsoftonline.com.Follow-up
A separate PR against
graphql-template-esmafter this release deletescodeRedemptionAuthorityand pins the client authority back to the hosting tenant.🤖 Generated with Claude Code