Skip to content

feat: redeem the authorization code at the authority it was minted at - #19

Merged
cuzzlor merged 3 commits into
mainfrom
redeem-code-at-mint-authority
Sep 4, 2026
Merged

cuzzlor merged 3 commits into
mainfrom
redeem-code-at-mint-authority

Conversation

@cuzzlor

@cuzzlor cuzzlor commented Sep 4, 2026 •

Copy link
Copy Markdown
Collaborator

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. authorizationUrlRequestOverride puts an authority on the authorize leg, so an app can send a visitor to the tenant its URL names. The token leg then called acquireTokenByCode with no authority, 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, 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 /organizations with 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.

  1. authority?: string on PKCEStartedSession.
  2. createLoginHandler resolves the override before writing the session, so the resolved authority can be stored.
  3. createAuthHandler spreads it into tokenRequest when the session carries one.

The type already allowed it: authority is not among the keys AuthorizationCodeRequest omits from CommonAuthorizationCodeRequest, so it arrives as authority?: string via BaseAuthRequest.

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 reads req.session now 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.ts gains three cases; auth-handler.spec.ts is new, reaching the reply handler by capturing what pkceAuthenticationMiddleware registers with app.get.

Case Asserts
Login, override names an authority the session carries it, and getAuthCodeUrl still receives it
Login, override names none no authority key on the session
Login, no override same
Reply, session carries an authority acquireTokenByCode receives it
Reply, session carries none the token request is unchanged from today

Both positive cases were confirmed to fail against main's src/index.ts and pass with the change. The reply-handler compatibility case asserts the authority key is absent, not merely undefined, and was confirmed to fail if the conditional spread is replaced by a plain authority,.

Locally: 12 tests pass, and npm run audit, npm run build (lint, types, rollup, attw) and prettier --check all exit 0.

Dev-tree audit advisories, cleared here

CI's Audit step was failing on seven advisories that predate this branch — package-lock.json was identical to main, 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:

Package Advisories Reached via Fix
fast-uri 4 high better-npm-audit → table → ajv 3.1.5 → 3.1.7
qs 2 moderate express 6.15.3 → 6.16.0
fflate 1 moderate @arethetypeswrong/cli 0.8.2 → 0.8.3

All 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:

  • fflate 0.8.3 breaks @arethetypeswrong/cli 0.18.2, which fails reading the packed tarball with Cannot read properties of undefined (reading 'filename'). attw therefore goes 0.18.2 → 0.18.5, keeping the repo's exact-pin convention. build:6-check-exports passes again.
  • esbuild keeps its existing .nsprc exception (1 low). tsx pins ~0.27.0, so it cannot reach the patched >=0.28.1 — exactly the condition that exception's own note anticipates.

package-lock.json is 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:

  • Sign in at <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 with tid = B.
  • The same URL with no parameter still signs in to A.
  • An in-flight session written by 2.1.0 — set one, upgrade, then complete the reply — redeems at the client authority and does not throw.

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 under login.microsoftonline.com.

Follow-up

A separate PR against graphql-template-esm after this release deletes codeRedemptionAuthority and pins the client authority back to the hosting tenant.

🤖 Generated with Claude Code

cuzzlor and others added 2 commits September 4, 2026 15:38
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
cuzzlor requested review from mderriey and robdmoore and a balanced review from Copilot September 4, 2026 08:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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.

Comment thread package.json
Comment thread src/auth-handler.spec.ts Outdated
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>
@cuzzlor
cuzzlor merged commit a41972c into main Sep 4, 2026
1 check passed
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.

3 participants