Skip to content

feat(auth): implement @fuzefront/auth — real verifier + middleware (#117) - #285

Merged
izzywdev merged 3 commits into
masterfrom
claude/auth-package-runtime
Jul 17, 2026
Merged

feat(auth): implement @fuzefront/auth — real verifier + middleware (#117)#285
izzywdev merged 3 commits into
masterfrom
claude/auth-package-runtime

Conversation

@izzywdev

Copy link
Copy Markdown
Owner

Fixes #117. @fuzefront/auth was a stub. Every entry point threw VERIFIER_UNAVAILABLE; the authz guard literally responded:

"@fuzefront/auth is contract-frozen; authz guard runtime not yet implemented"

So the package consuming services are told to install gave them no way to verify a FuzeFront token or check a permission. Cross-service AuthN/AuthZ was a contract, not a capability. This makes it real — installable as middleware.

Public signatures are unchanged from the v0.1.0 freeze: code written against the frozen types keeps compiling, it now also runs.

Install & use

import { createVerifier, requireAuth, requireRoles } from '@fuzefront/auth'

const verifier = createVerifier({
  mode: 'legacy-hs256',
  secret: process.env.JWT_SECRET!,
  resolver: { async resolve(userId) { /* hydrate tenantId/roles */ } },
})

app.use('/api/private', requireAuth({ verifier }))         // 401 if not authenticated
app.get('/api/private/admin', requireRoles(['admin']), h)  // 403 if not permitted
app.get('/api/private/me', (req, res) => res.json(req.identity))

Migrating to federated JWKS later is a config change, not a code change — swap mode to federated-jwks with an issuer.

Runtime

One dependency — jose covers HS256 today and RS256/JWKS for the target, so consumers don't pull two crypto libs.

  • legacy-hs256 — today's session token; optional OutOfBandResolver hydrates the tenantId/roles the token doesn't carry.
  • federated-jwks — RS256/ES256 against the issuer's JWKS with iss/aud validation, OIDC discovery when jwksUri is omitted, JWKS cached per issuer.
  • requireAuth / requireRoles / requireTenant — real Express middleware.

Security properties — deliberate, not incidental

  • Algorithms are PINNED per mode. Unpinned algorithms is exactly what enables alg: none and RS256-public-key-abused-as-HMAC-secret confusion. Tested.
  • tenantId: null means UNRESOLVED, never a wildcard. requireTenant denies. Treating null as "any tenant" would hand every tenant's data to any valid legacy token lacking a resolver.
  • A resolver failure DENIES rather than returning empty roles — otherwise an outage is indistinguishable from a genuine permission decision, and gets misread as one.
  • requireAuth never calls next() on an unauthenticated request; the promise is driven explicitly so a rejection denies rather than hanging the request.
  • 401 (authn) vs 403 (authz) kept distinct so clients aren't sent into login loops over a permission problem.
  • Still never mints tokens. In federated-jwks mode a consumer holds no signing key at all — it can verify without gaining the power to forge.

Tests — 31, weighted toward the denial paths

A false allow here is a family-wide breach; a false deny is an outage. So the happy path gets one test and the refusals get the rest: algorithm confusion, forged signature, expiry, wrong issuer, wrong audience, missing subject, resolver failure, unresolved tenant, and a guard mounted without requireAuth.

The package had no test config at all — its tsconfig is build-shaped (types: ["node"], include: src only), so tests/tsconfig.json + jest.config.js were prerequisites before a single test could run. The federated tests serve a real JWKS over loopback rather than stubbing fetch, because createRemoteJWKSet does its own fetching — that also exercises discovery for real.

Verified (clean node:20 container, actually run)

tsc --noEmit  → exit 0
jest          → Test Suites: 2 passed | Tests: 31 passed, 31 total

The type-checker caught a real gap mid-work: Identity requires authMode, which I'd missed — now populated per verifier, along with issuedAt/claims.

hold — master is deploy-on-push.

Co-Authored-By: Claude

)

@fuzefront/auth shipped as a contract freeze: every entry point threw
VERIFIER_UNAVAILABLE. The authz guard literally responded

    "@fuzefront/auth is contract-frozen; authz guard runtime not yet implemented"

So the package consuming services are told to install gave them NO way to verify
a FuzeFront token or check a permission. Cross-service AuthN/AuthZ was a contract,
not a capability. This makes it real.

Public signatures are UNCHANGED from the v0.1.0 freeze — code written against the
frozen types keeps compiling; it now also runs.

Runtime (jose — one dependency covers HS256 today and RS256/JWKS for the target,
so consumers don't pull two crypto libs):
  - legacy-hs256: today's session token; optional OutOfBandResolver hydrates the
    tenantId/roles the token doesn't carry.
  - federated-jwks: RS256/ES256 against the issuer's JWKS with iss/aud validation
    and OIDC discovery when jwksUri is omitted; JWKS cached per issuer.
  - requireAuth / requireRoles / requireTenant: real Express middleware.

Security properties, made explicit rather than incidental:
  - Algorithms PINNED per mode. Unpinned `algorithms` is what enables `alg: none`
    and RS256-public-key-as-HMAC-secret confusion attacks.
  - tenantId: null means UNRESOLVED, never a wildcard — requireTenant denies.
    Treating null as "any tenant" would hand every tenant's data to any valid
    legacy token lacking a resolver.
  - A resolver failure DENIES rather than returning empty roles, which would be
    indistinguishable from a genuine permission decision and would mask an outage.
  - requireAuth never calls next() on an unauthenticated request; the promise is
    driven explicitly so a rejection denies rather than hanging the request.
  - 401 (authn) and 403 (authz) kept distinct so clients aren't sent into login
    loops over a permission problem.
  - Still never mints tokens; in federated-jwks mode a consumer holds no key.

Tests: 31, weighted toward denial paths (algorithm confusion, forged signature,
expiry, wrong issuer/audience, missing subject, resolver failure, unresolved
tenant, guard-without-requireAuth). The package had NO test config at all — its
tsconfig is build-shaped (types: ["node"], include: src only), so tests/tsconfig.json
+ jest.config.js were needed before a single test could run.

Verified in a clean node:20 container: tsc --noEmit -> exit 0; jest -> 31/31 pass.

Fixes #117.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7
@izzywdev izzywdev added the hold label Jul 16, 2026
@github-actions
github-actions Bot enabled auto-merge (squash) July 16, 2026 20:27
@github-actions

Copy link
Copy Markdown
Contributor

Automated code review (gate-code-review)

Credit balance is too low

Report-only — this check never blocks merge.

@izzywdev izzywdev removed the hold label Jul 16, 2026
Comment thread packages/auth/tests/middleware.test.ts Fixed
Comment thread packages/auth/tests/middleware.test.ts Fixed
Comment thread packages/auth/tests/middleware.test.ts Fixed
Comment thread packages/auth/tests/verifier.test.ts Fixed
Comment thread packages/auth/tests/verifier.test.ts Fixed
Comment thread packages/auth/tests/verifier.test.ts Fixed
Comment thread packages/auth/tests/verifier.test.ts Fixed
Comment thread packages/auth/tests/verifier.test.ts Fixed
Comment thread packages/auth/tests/verifier.test.ts Fixed
Comment thread packages/auth/tests/verifier.test.ts Fixed
The rule fires on `new SignJWT(...)` / `jwt.sign(...)` anywhere, with no path
exclusions — so it flagged @fuzefront/auth's own test suite 10 times.

Proving a verifier REJECTS a forged, expired, or wrong-issuer token requires
minting exactly those tokens as fixtures. There is no other way to test it. The
rule's own message says "replace it with FuzeFront token verification
(@fuzefront/auth)" — and the code it flagged IS @fuzefront/auth.

A rule that cries wolf on the verifier it points you to is one people learn to
scroll past, which costs far more than the finding is worth. Excluding tests
keeps it high-signal where it actually matters: production code minting user
tokens, which is the thing the architecture forbids.

Co-Authored-By: Claude <claude-opus-4-8> <noreply@anthropic.com>
Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7
@github-actions

Copy link
Copy Markdown
Contributor

Automated code review (gate-code-review)

Credit balance is too low

Report-only — this check never blocks merge.

@github-actions

Copy link
Copy Markdown
Contributor

Automated code review (gate-code-review)

Credit balance is too low

Report-only — this check never blocks merge.

@izzywdev
izzywdev merged commit 9ab06ec into master Jul 17, 2026
46 of 47 checks passed
@izzywdev
izzywdev deleted the claude/auth-package-runtime branch July 17, 2026 06:04
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.

[feature-request] Publish @fuzefront/auth — the consumable authN/authZ client every product needs

2 participants