# Authentication Portal Jwks

> Maintain portal public signing-key discovery at .well-known/jwks.json, reusable admin API directive parsing, opt-in admin private-key export, KMS key serialization, asymmetric issuer selection, base-path routing, and JWT verification tests. Excludes upstream OAuth provider JWKS consumption.

- Skill: `greenpau/authentication-portal-jwks` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add greenpau/authentication-portal-jwks`
- Raw SKILL.md: https://api.skillmd.com/api/skills/greenpau/authentication-portal-jwks/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Product & Planning
- Author: greenpau (https://skillmd.com/u/greenpau)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/greenpau/authentication-portal-jwks

---


# Authentication Portal JWKS

## Ownership

`pkg/authn/serve_http.go` routes public discovery to
`pkg/authn/handle_http_jwks.go`. `pkg/kms/jwks.go` owns `CryptoKeyStore.GetJWKS`
and public-key serialization. Keep signing-key selection aligned with
`CryptoKeyStore.SignToken(nil, nil, user)` in `pkg/kms/crypto_keystore.go`.
All current portal access-token issuance paths use this default selection.

`pkg/authn/handle_api_private_keys.go` owns the explicitly enabled admin export
route. `pkg/kms/jwks_private_keys.go` owns `GetJWKSPrivateKeys`. Both public
and private exports use `getJWKSSigningKeys` for identical selection, ordering,
and deduplication; keep private encoding separate from `GetJWKS`.

This is portal-issued JWT discovery. Upstream OAuth key consumption belongs to
[oauth-identity-provider](../oauth-identity-provider/SKILL.md) and
`pkg/idp/oauth/jwks.go`; its `JwksKey` model includes shared secrets and must
not be reused as a public export model. JWTs here use signatures, not
asymmetric payload encryption.

## HTTP Contract

The route is the complete `/.well-known/jwks.json` suffix beneath the portal
mount. Root, `/auth/`, `/xauth/`, and `/tenant/xauth/` mounts expose,
respectively, `/.well-known/jwks.json`, `/auth/.well-known/jwks.json`,
`/xauth/.well-known/jwks.json`, and
`/tenant/xauth/.well-known/jwks.json`. The embedding server determines which
paths reach a portal; the library has no general configured mount prefix.

Match the complete suffix on Go's decoded `r.URL.Path`. Do not accept an extra
trailing slash, filename suffix, or a match only in the query. Route before
API/QR-code dispatch and JSON negotiation, so nested mounts containing those
segments, `format=json`, and JSON request headers work identically.

- GET returns 200 with `application/jwk-set+json` and a nonempty `keys` array
  when the selected issuer is asymmetric.
- HEAD returns the same success headers and Content-Length, without a body.
- Missing signing keys or a symmetric selected issuer return 404, including
  a store with HMAC first and RSA/EC later. Verification-only public keys do
  not enable this endpoint.
- Other methods return 405 with `Allow: GET, HEAD`.
- Invalid asymmetric signing material returns 500 without key material or
  internal error text in the response. Response write errors are propagated.

Discovery does not authorize credentials, allocate sessions, set/delete cookies,
or redirect to login. Responses use `Cache-Control: no-store` and `nosniff`;
configuration reload can change keys or switch an issuer to HMAC. Preserve
these properties when adding caching or middleware.

Both discovery and admin export always return a JSON object with a `keys`
array on success, even when it contains only one entry. Never collapse a
singleton into a key object. Preserve this contract for future key rotation;
consumers must iterate the array and use `kid` when present. Public entries
are JWKs; admin entries pair `public_key` with `private_key` in every format.

## Key Selection and Encoding

The first non-system signing key determines whether discovery is available.
When it is asymmetric, publish the public parts of configured non-system RSA,
ECDSA, and Ed25519 signing keys, in signing order, deduplicating identical JWK entries.
Exclude symmetric keys, verification-only keys, and system keys. Derive public
parameters from the actual private signing operator, so sign-only keys work
and a separately loaded verifier cannot substitute another issuer's key.

`publicJSONWebKey` is an explicit allowlist of public parameters. Never marshal
`CryptoKey`, `CryptoKeyOperator`, private keys, configuration, or shared secrets
into the public JWKS response. The format follows [RFC 7517](https://www.rfc-editor.org/rfc/rfc7517.html)
and [RFC 7518 section 6](https://www.rfc-editor.org/rfc/rfc7518.html#section-6):

| Signing key | Public JWK parameters |
| --- | --- |
| RSA | `kty: RSA`, `n`, `e`; integers use minimal unsigned big-endian bytes |
| ECDSA P-256 | `kty: EC`, `crv: P-256`, 32-byte `x` and `y` |
| ECDSA P-384 | `kty: EC`, `crv: P-384`, 48-byte `x` and `y` |
| ECDSA P-521 | `kty: EC`, `crv: P-521`, 66-byte `x` and `y` |
| Ed25519 | `kty: OKP`, `crv: Ed25519`, 32-byte `x`; no `y` |

Encode integers/coordinates with unpadded base64url. EC coordinates require
leading zero padding; `big.Int.Bytes()` alone is insufficient. Use
`ecdsa.PublicKey.Bytes` for validated SEC 1 points and split its fixed-width
coordinates. Consumer tests reconstruct points with
`ecdsa.ParseUncompressedPublicKey` and verify real signatures. Do not replace
point validation with unchecked coordinate construction. Advertise
`use: sig` and the operator's actual default signing `alg`. Emit `kid` exactly
when signing injects it. The default key ID `0` is omitted in both JWT and
JWK; do not invent a discovery-only ID or change existing JWT headers.

The store is configured before serving requests. `GetJWKS` is read-only and
does not add concurrent key-mutation support to KMS. Do not introduce mutable
request-time caches or serialize the global shared buffer.

## Configuration and Consumer Integration

Without explicit keys, `NewCryptoKeyStoreConfig` defaults to ES512 and
`CryptoKeyStore.AutoGenerate` creates an ECDSA P-521 private/public pair.
`generateKey` reuses it via `shared.Buffer` under the configured tag within
the process. This sharing is not HMAC: the key is asymmetric. It is not
persisted across process restarts or shared automatically between servers.

For persistent signing material, configure the portal with, for example:

```text
crypto key signing-v1 sign-verify from file /etc/authcrunch/signing-key.pem
```

RSA, supported ECDSA, and Ed25519 PKCS#8 private PEM keys work. `sign` also works when portal
verification is configured separately. A literal shared secret, such as
`crypto key sign-verify <shared-secret>`, selects HMAC and yields 404. Merely
loading a public RSA/EC/Ed25519 verifier does not configure an asymmetric issuer.

Configure an application's verifier with the trusted portal JWKS URL and its
expected algorithms, issuer, and audience. Use matching `kid` values when
present; the autogenerated/default-key response has no `kid`. Prefer explicit
distinct IDs for configured keys to avoid ambiguous selection in a multi-key
set. Publication reflects current configuration; it does not retain removed
keys for rollover or coordinate keys across processes. This endpoint does not
add `jku` JWT headers or OpenID discovery metadata.

## Ed25519 Signing and Verification

`pkg/kms/ed25519.go` supports both exact JOSE names `EdDSA` (RFC 8037)
and `Ed25519` (RFC 9864), using pure `crypto/ed25519` over the original JWS
input. No prehash, context, Ed448, or X25519 signing is supported. The pinned
JWT library registers only `EdDSA`; `internal/jwtutil`, shared by KMS and
OAuth, registers a separate `Ed25519` method
whose `Alg()` returns `Ed25519`. Keep algorithm allowlists exact and never
rewrite a JWT header before verifying its signature.

Existing PEM loading accepts Ed25519 PKCS#8 private keys and PKIX public keys
through files, directories, or PEM-valued environment variables:

```text
crypto key sign-verify from file /etc/authcrunch/ed25519-private.pem
crypto key verify from file /etc/authcrunch/ed25519-public.pem
```

These are separate issuer/verifier examples. Imported Ed25519 private keys
sign with `EdDSA` by default. Verification accepts both names with the same
public key. The existing `CryptoKey.SignToken` and `CryptoKeyStore.SignToken`
method argument can explicitly select either name. A PEM has no JOSE-label
preference; reimporting an exported generated key restores its material and
uses the imported `EdDSA` default. There is no new Caddy directive to select
the other label for an imported key.

The existing `crypto default autogenerate algorithm` setting additionally
accepts `EdDSA` and `Ed25519`. Each selects Ed25519 key generation and its
corresponding default signing label. Omitted settings retain ES512 generation;
existing HMAC/RSA/ECDSA selection, per-key defaults, token names/lifetimes,
and first-eligible signing order remain unchanged. Explicit method arguments
do not search later keys for a matching algorithm.

Autogeneration tags still share key material within a process. EdDSA and
Ed25519 configurations may share a tag while retaining their own labels.
Reusing a tag across incompatible key families fails configuration. The
existing tag directive can isolate unrelated issuers. Explicit verification
keys continue to suppress autogeneration just as other explicit keys do.
An Ed25519 private key configured for `verify` contributes only its public
verification operator and cannot become the portal signer or an exported key.

Public JWKS advertises each key's actual default signing label, preserving the
existing key-order and `kid` rules. An explicit Go signing-method override does
not alter discovery metadata, just as for existing RSA methods. Consumers
verifying both labels must configure an appropriate algorithm policy.

`pkg/kms/ed25519_test.go` covers both directions, exact allowlists, the RFC 8037
signature vector, existing key sources, role boundaries, invalid key material,
algorithm confusion, generation sharing, exports, and parser fuzzing.
`pkg/authn/ed25519_e2e_test.go` covers TLS login/refresh compatibility, both
verification labels, denied admin export, and PEM persistence. The JWKS E2E
format suite also covers OKP, mixed RSA/EC/Ed25519 sets, and independent
standard-library signature verification. Run:

```sh
make test TEST_DIR='./pkg/kms ./pkg/authn' TEST='Ed25519|JWKS|TestE2EPortalSigningAndRefreshCompatibility' COVERAGE_DIR=.coverage/ed25519
go test -mod=readonly -race ./pkg/kms -run '^$' -fuzz '^FuzzEd25519TokenParsing$' -fuzztime=10000x -parallel=2
make ci-check
```

## Admin Private-Key Export

`GET <mount>/api/server/private_keys` returns the private signing keys paired
with the published public JWKs. For `/xauth/`, the URL is
`/xauth/api/server/private_keys`. The endpoint is disabled by default. Enable
both API flags in portal configuration:

```json
{
  "api": {
    "admin_enabled": true,
    "admin_fetch_private_keys_enabled": true
  }
}
```

The persisted Go field is `APIConfig.AdminFetchPrivateKeysEnabled`; it never
implicitly enables `AdminEnabled`. Its JSON/XML/YAML tag is
`admin_fetch_private_keys_enabled`. Treat the two flags independently:

| Admin API enabled | Private export opted in | Authorized admin GET |
| --- | --- | --- |
| false | false | 404 |
| true | false | 404 |
| false | true | 404 |
| true | true | 200 when an asymmetric signing key is available |

### Admin API Directive Configuration

`pkg/authn/admin_api/parser.NewAdminAPIConfigFromDirectives` accepts a list of
complete statements encoded with `cfgutil.EncodeArgs` and decodes them with
`cfgutil.DecodeArgs`. Its result is `*authn.AdminAPIConfig`, which contains only
`Enabled` and `FetchPrivateKeysEnabled`. Both default to false; an empty list
does not enable either setting. Extend this parser whenever adding admin API
configuration, keeping `authn.AdminAPIConfig` as its result and
`PortalConfig.ConfigureAdminAPI` as the typed application boundary. The exact
grammar is:

```text
enable admin api
enable admin api private key export
```

Each directive also accepts `disable` in place of `enable`. Retain the existing
`enable admin api` spelling; enabling private-key export is a separate explicit
setting. Encode each keyword as a separate token. Boolean literals, underscore
keys, grouped keywords, extra arguments, unknown directives, duplicate or
conflicting settings, and multiline records are rejected without a partial
configuration or input values in errors. Reject empty tokens before encoding,
because `EncodeArgs` can trim a final empty field.

Embedding adapters collect all admin API statements for one portal before
calling the parser, so duplicates are detected across the whole configuration.
Adapters own tokenization, placeholder expansion, and enclosing block traversal;
pass no braces or unrelated directives. After successful parsing, call
`PortalConfig.ConfigureAdminAPI(admin)`. It snapshots the admin settings into
the portal's existing `APIConfig`, initializes it when absent, preserves
`ProfileEnabled`, and retains the flat JSON/XML/YAML fields shown above. Nil
input is rejected without mutation; a zero-value `AdminAPIConfig` clears both
admin flags. Configure before serving requests. Do not replace the combined
profile/admin config with an admin-only model or store competing admin settings.

Keep the parser in its public package and the typed configuration/application
method in `pkg/authn`, without a runtime-to-parser import. Consumer handler
wiring remains separate work under the
[repository scope](../coding-directives/SKILL.md#repository-scope); do not
change sibling directories or claim to validate their directive handlers.
Public JWKS discovery needs no directive and remains independent of both flags.

### Export Authorization and Representation

Keep the route inside `handleAPI`, after normal token authorization. The
export handler checks both flags, requires `authorizedRole` with `role.Admin`
and authenticated request state, and then accepts GET only. Ordinary portal
users and anonymous callers receive 403; invalid, tampered, or expired bearer
tokens receive 401 from the common API authorization path. Disabled export or
no matching asymmetric signer returns 404. Unsupported methods from authorized
admins receive 405 with `Allow: GET`. Read the flags on every request, even
when the admin token is cached.

A successful JSON response has a `keys` array. Each entry contains:

| Field | Content |
| --- | --- |
| `public_key` | The exact public JWK, including the existing `kid` behavior |
| `private_key` | The matching private key in the requested format; defaults to an unencrypted PKCS#8 PEM string |

The `format` and `encoding` query parameters select the private-key
representation. The surrounding response always remains JSON, and
`public_key` always remains a public JWK.

| `format` | Supported keys | `encoding` | `private_key` value |
| --- | --- | --- | --- |
| `pkcs8` (default) | RSA, ECDSA, and Ed25519 | `pem` (default), `der` | PKCS#8 PEM string or standard-base64 DER string |
| `pkcs1` | RSA only | `pem` (default), `der` | RSA PRIVATE KEY PEM string or standard-base64 DER string |
| `sec1` | ECDSA only | `pem` (default), `der` | EC PRIVATE KEY PEM string or standard-base64 DER string |
| `jwk` | RSA, ECDSA, and Ed25519 | `json` (default) | Private JWK object, including its public parameters |

For example, request `?format=pkcs8&encoding=der`, `?format=pkcs1`,
`?format=sec1`, or `?format=jwk`. `format=json` retains the existing JSON API
negotiation convention as an alias for `pkcs8`. An omitted format with
`encoding=der` selects PKCS#8 DER. Selectors are lowercase and case-sensitive.

Malformed query escaping, duplicate selectors, explicitly empty selectors,
unknown values, and incompatible combinations return 400 after configuration
and admin checks. A mixed RSA/EC set cannot be exported as PKCS#1 or SEC1:
reject the entire request instead of silently omitting keys. Keep these option
errors distinct from invalid signing material, which returns a generic 500.
`GetJWKSPrivateKeys(format, encoding)` enforces the same option contract for
KMS callers; empty arguments there select defaults.

Private JWK encoding follows RFC 7518 section 6: RSA includes `d`, `p`, `q`,
`dp`, `dq`, `qi`, and `oth` for additional primes; EC includes a fixed-width
`d` scalar. Ed25519 uses the RFC 8037 32-byte seed for `d`, never Go's
64-byte private key. The public serializer must never use `privateJSONWebKey`.

The selected issuer must be asymmetric, just as for public discovery. Export
includes autogenerated keys and configured signing keys, while excluding
HMAC/shared secrets, system keys, and verification-only keys. Do not accept
file paths or key material from request parameters. Portals using the same
autogeneration tag within a process share the exported signing credential.
Enabling export for one portal grants its admins signing authority for every
portal or verifier that trusts that key. Use distinct autogeneration tags or
configured key pairs when portals belong to separate trust domains.
The export contains credentials capable of issuing tokens; retain the existing
admin boundary and explicit opt-in, `Cache-Control: no-store`, JSON content type,
and `nosniff`.
Never log the private response or include encoder details in error responses.

Validate ECDSA scalar bounds and its correspondence to the public point before
marshaling. `PrivateKey.Bytes` validates scalar and point separately; reconstruct
with `ecdsa.ParseRawPrivateKey` and compare the derived public key before export.
Do not assume that encoding validates their pairing. Retain nil-coordinate and
nil-scalar guards for manually constructed legacy keys: the standard encoders
assume those fields are initialized. A defensive nil-scalar read and malformed
legacy-key test construction deliberately retain deprecated raw fields.
Safe parsers cannot construct those negative fixtures. Multiprime RSA `oth`
export retains Go's compatibility CRT values and independent precomputation
checks; do not remove a supported format merely to silence a diagnostic. Marshal RSA from a
struct copy with separate precomputation state: x509 can modify RSA
precomputation, and export must not mutate a key used by concurrent signers.
Encode the complete response before writing it; an invalid later key must not
produce a partial private-key export. Such failures return a generic 500.

## Validation

Run the focused report lifecycle, then the complete repository gate:

```sh
make test TEST_DIR='./pkg/kms ./pkg/authn' TEST='JWKS|AdminFetchPrivateKeys' COVERAGE_DIR=.coverage/jwks
make test TEST_DIR='./pkg/authn/admin_api/parser ./pkg/authn ./internal/tag' TEST='AdminAPI|AdminFetchPrivateKeys|TestE2EPortalJWKSAuthorization|TagCompliance' COVERAGE_DIR=.coverage/admin-api
make test TEST_DIR='./pkg/authn' TEST='^TestE2EPortalJWKS' COVERAGE_DIR=.coverage/jwks-e2e
go test -mod=readonly -race ./pkg/kms -run '^$' -fuzz '^FuzzJWKSAdminTokenParsing$' -fuzztime=20s -parallel=4
make ci-check
```

`pkg/kms/jwks_test.go` verifies public-only output, actual signature verification,
all supported RSA methods and EC curves, leading-zero coordinates, default
generation, mixed stores, duplicates, sign-only keys, and invalid material.
`pkg/authn/handle_http_jwks_test.go` exercises `ServeHTTP`, root/custom/nested
mounts, negotiation precedence, route boundaries, methods/statuses, cookie
independence, concurrent requests, and real password login followed by JWT
verification using only the published key. Preserve tests that verify signatures
independently of KMS's `ProvideKey`; comparing JSON shapes alone is insufficient.

`pkg/kms/jwks_private_keys_test.go` parses every PEM/DER/JWK format and signs
tokens with it for verification against public JWKS, including EC scalar
padding and multiprime RSA parameters. It covers exclusions/invalid material,
incompatible format combinations, and concurrent export/signing without RSA
mutation.
`pkg/authn/handle_api_private_keys_test.go` exercises default-disabled and
two-flag gating, admin/user/anonymous/invalid-token authorization, configuration
changes after cached authorization, route boundaries, log/public-response
non-disclosure for every format, selector parsing, and generic export failures.
It checks both HTTP endpoints with one key, multiple RSA keys, and mixed
RSA/EC keys across mounts, preserving list shape and per-entry pairing.
`pkg/authn/api_config_test.go`
covers flag defaults and JSON/XML/YAML serialization.
`pkg/authn/admin_api/parser/parser_test.go` covers the public directive grammar,
independent flags, duplicates/conflicts, malformed input, error redaction, and
an executable configuration example. `pkg/authn/admin_api_config_test.go`
covers portal composition, profile preservation, independent snapshots, clearing
previous opt-ins, nil errors, and JSON/XML/YAML compatibility. Register
`authn.AdminAPIConfig` in the struct-tag compliance suite.

`pkg/authn/jwks_e2e_test.go` uses the external `authn_test` package, a real TLS
listener, a temporary local identity database, and `authclient` password login.
Preserve this E2E boundary: requests pass through public production APIs without
injecting authenticated users or accessing a portal's internal key store.
Configure `RawCryptoKeyStoreConfig`, because `PortalConfig.Validate` derives
`CryptoKeyStoreConfig` from those directives. Supply `cookie.NewConfig()` as in
the other real-portal fixtures. Use unique autogeneration tags; P-256/P-384
fixtures use generated PEM files because portal autogeneration supports ES512.

The E2E suite independently verifies login JWTs from fetched public keys and
signs with every exported PEM/DER/JWK representation. It exercises configured
and autogenerated keys, all supported EC curves, single/multiple-key arrays,
root/custom/nested mounts, public HEAD, both opt-in flags, real admin/member
roles, invalid signatures, methods, malformed selectors, HMAC issuers, and
concurrent login/discovery/export. It also saves exported PKCS#8, PKCS#1, and
SEC1 PEM to private temporary files, starts a fresh portal with that key file,
and verifies old and new tokens. The configured-rollover case reverses two RSA
signers across portal instances and verifies both generations of tokens using
the retained key list. It tests configuration replacement, not an automatic
rotation service. Keep these tests in the default suite and extend them when
changing either endpoint or key representation.

`TestE2EPortalJWKSAuthorization` imports the public admin parser, applies its
result with `PortalConfig.ConfigureAdminAPI`, and persists that config through
JSON before real TLS login. Preserve coverage for omitted/default/disabled
settings, admin-only access, export-only denial, both flags enabled, ordinary
admin metadata access, role denials, and public JWKS independence.

`pkg/authn/jwks_security_e2e_test.go` tests unsigned and tampered JWTs,
RSA-to-HMAC algorithm confusion, malformed signed identity claims, encoded
and raw HTTP request targets, credential transports, and absence of CORS/JSONP
disclosure. Preserve normal cookie, header, query, and bearer authorization;
none may turn a member into an admin. Trace `Path`, `RawPath`, and `RequestURI`
at the real handler boundary for adversarial path tests. The fixture's trace
header is test-only; never enable it for queries carrying credentials.

`pkg/kms/crypto_keystore_security_test.go` covers identity-claim type validation
and fuzzes untrusted tokens. Even correctly signed JWTs must reject non-string
`iss`, `mail`, `email`, `sub`, `name`, and `jti` without panicking, including
expired tokens used to construct login hints. Use JWT error identities rather
than matching expiration error text. Keep valid login and expired-token
behavior covered when modifying the shared parser.

Retain concurrent real logins in the E2E suite: they exercise claim preparation
as well as signing. `injectPortalRoles` in `handle_http_login.go` must treat the
validated configuration's reserved-role map as read-only; even writing its
existing value races with other requests. `TestInjectPortalRoles` checks role
normalization under concurrent calls with shared configuration.

