Skip to content

fix(auth): JWT_PUBLIC_KEY is documented as optional but omitting it fails at boot (EdDSA derive fallback is broken) #359

Description

@tahirayan

Summary

.env.example tells operators the EdDSA public key is optional and "can be derived from
private key". It cannot. Booting with JWT_PRIVATE_KEY set and JWT_PUBLIC_KEY unset throws
Failed to derive public key from private key. Please provide JWT_PUBLIC_KEY in environment variables. and the server never starts.

The repo already contains the correct helper for this, sitting next to the broken call site,
and its doc comment already explains why the broken approach cannot work.

Reproduction

Set a valid PKCS#8 Ed25519 JWT_PRIVATE_KEY, leave JWT_PUBLIC_KEY and
JWT_PUBLIC_KEY_PATH unset, and start the server. It throws during jwtPlugin registration.

Found incidentally while writing the openapi-export script for the documentation site
(#347): the first run failed on this, with no live services involved, so it is not
environment-specific.

Technical Details

libs/fastify/plugins/jwt/src/lib/fastify-plugin-jwt.ts:85-96 derives the public half via
exportPublicKeyPem(privateKey):

// Derive public key from private key
// For EdDSA keys, we can export the public key from the private key
// Note: This requires the key to be extractable, which imported keys are by default in jose
try {
  const publicKeyPem = await exportPublicKeyPem(privateKey);
  publicKey = await importPublicKey(publicKeyPem);
} catch {
  throw new Error(
    'Failed to derive public key from private key. Please provide JWT_PUBLIC_KEY in environment variables.'
  );
}

exportPublicKeyPem (libs/server/jwt/src/lib/jose-utils.ts:77-79) is a direct pass-through
to jose's exportSPKI, called on an imported private key. That comment's premise — that
imported keys are extractable in jose — is what is wrong.

The codebase already documents the correct answer.
libs/server/jwt/src/lib/key-management.ts:91-95, on derivePublicKeyPemFromPrivate:

Uses node:crypto's createPublicKey, which computes ONLY the public half — no private
material is ever emitted. This is the deliberate path (rather than jose's exportSPKI)
because jose 6 imports private keys as NON-extractable WebCrypto CryptoKeys, from which
the public key cannot be re-exported; createPublicKey derives it directly from the PEM.

That helper is algorithm-agnostic and is already used for the RS256 path (#309). The EdDSA
path never got switched over, so the catch block converts a guaranteed failure into a
misleading "please provide the key" message — which reads as operator error rather than a
defect.

Note also that the derive call receives the imported KeyLike, whereas
derivePublicKeyPemFromPrivate takes the private-key PEM. The fix therefore needs the
raw options.privateKey string, which the plugin already has in scope.

Documentation that promises the broken behaviour

  • .env.example:191-193 — "EdDSA public key in PEM format (optional …) / If not provided, can be derived from private key"
  • .env.example:207-209 — same claim for JWT_PUBLIC_KEY_PATH
  • .env.example:213 — "Note: Provide either JWT_PUBLIC_KEY or JWT_PUBLIC_KEY_PATH (both are optional)"
  • docs/oidf-op-certification-runbook.md — "QAuth derives the public key from the private key if you omit it."

Either the code or all four statements are wrong. Deriving is the better behaviour and is
already implemented, so the code should be fixed rather than the docs weakened.

Tasks

  • Switch the EdDSA fallback in fastify-plugin-jwt.ts to derivePublicKeyPemFromPrivate(options.privateKey)
  • Replace the bare catch {} so a genuine failure surfaces its cause instead of being relabelled as missing configuration
  • Add a test that boots the plugin with a private key only and asserts a working verify path — there is currently no coverage of the omitted-public-key case, which is why this survived
  • Decide whether exportPublicKeyPem in jose-utils.ts still has a legitimate caller; if not, remove it so the trap cannot be re-entered

Acceptance Criteria

  • A deployment configured exactly as .env.example documents (private key only) boots and verifies tokens
  • The RS256 and EdDSA paths derive their public halves the same way
  • A test fails if the derive path regresses
  • No misleading error message remains for a case that is not the operator's fault

References

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions