Documentation
¶
Overview ¶
Package jwksproxyhttp — cluster-ВНУТРЕННИЙ публикатор наборов проверочных ключей.
Записей ДВЕ, и у каждой свой объявленный путь ¶
Платформа чеканит свои токены сама (задача #897), поэтому публикуются две записи, а не одна:
- НАША (keyset.go) — ПРОЕКЦИЯ ключницы: только наши ключи, только публичный материал, собственного кэша нет by construction;
- ЗЕРКАЛО прежнего издателя (этот файл) — тонкий кэширующий обратный посредник его публичного набора, побайтовое равенство источнику. Оно сохраняется до последней фазы отказа от внешнего OAuth-сервера и уходит вместе с ним: запись, которой больше нечего зеркалить, — находка, а не «оставим на всякий случай».
Объединять наборы в один документ было бы дешевле и уничтожило бы ровно ту защиту, ради которой развязка заводится: ключ одного издателя проверял бы токен, объявляющий другого. Привязка «издатель → путь» — в binding.go.
Что верно ИМЕННО для зеркала ¶
Оно отдаёт чужие идентификаторы ключей и НИКОГДА наши: наши живут в своей записи. Наш идентификатор здесь был бы гарантированным промахом проверки — потребитель этой записи ищет ключи прежнего издателя.
Fail-closed. A cold cache + an unavailable Hydra (network error / non-200 / empty keyset / timeout) yields 502 — never an empty 200, never a substitute keyset. A warm cache within TTL survives a brief Hydra blip (bounded-stale); once the TTL elapses with Hydra still down the endpoint degrades to fail-closed, never indefinitely-stale.
Per-call timeout. The upstream fetch uses a dedicated http.Client WITH a Timeout plus a per-request context deadline — never http.DefaultClient: DefaultClient has no Timeout, so a hung/half-open Hydra would wedge the goroutine forever).
Снятие authN — задокументированное исключение, и его обоснование СМЕНИЛОСЬ ¶
Маршруты НАБОРА КЛЮЧЕЙ не требуют аутентификации осознанно. Прежде это обосновывалось тем, что публикуется ЧУЖОЕ зеркало; после того как платформа стала чеканить сама, обоснование другое и записано здесь, чтобы двух мест об одном предмете не завелось: на проводе — ТОЛЬКО ПУБЛИЧНЫЙ МАТЕРИАЛ проверки подписи (наш в одной записи, прежнего издателя в другой), в форме стандартного документа, на cluster-ВНУТРЕННЕМ слушателе (:9097, никогда внешнем — ban #6), под односторонней TLS с сертификатом внутреннего удостоверяющего центра.
Требовать сертификат у ПОТРЕБИТЕЛЯ набора нельзя: он обязан оставаться origin-agnostic, и взаимная TLS сломала бы ровно это свойство.
Но соседняя поверхность того же слушателя обоснованием НЕ пользуется ¶
Авторитет отзыва (internal/handler/tokenintrospecthttp) стоит на этом же слушателе и принимает ПРЕДЪЯВЛЕННЫЙ ТОКЕН, а не отдаёт публичный материал. Обоснование выше на него не распространяется, поэтому он требует проверенного клиентского сертификата САМ, а слушатель переводится в режим «сертификат запрашивается и верифицируется, если предъявлен» — набор ключей при этом остаётся доступен без сертификата.
Не добавляйте здесь authN-гейт, не пересмотрев это решение (и TLS-клиента потребителя набора).
Index ¶
Constants ¶
const WellKnownJWKSPath = "/.well-known/jwks.json"
WellKnownJWKSPath — the standard OIDC JWKS well-known path this proxy serves.
Variables ¶
This section is empty.
Functions ¶
Types ¶
type Binding ¶
type Binding struct {
// contains filtered or unexported fields
}
Binding — объявленная привязка «издатель → путь → обработчик».
func NewBinding ¶
NewBinding строит привязку, ОТКАЗЫВАЯ в вырожденной.
Это и есть страж старта записи источника: издатель, объявленный принимаемым, но не имеющий записи, — отказ в старте, а не молчаливый перебор записей подряд. Отказ здесь — третий экземпляр класса «пустое значение означает „не сужаем“», который дерево уже закрывает на двух других перечнях.
func (Binding) PathOf ¶
PathOf резолвит объявленного издателя в путь его записи.
Издатель употребляется ТОЛЬКО как ключ поиска в объявленной таблице: не резолвится — отказ. Ни одна часть пути, имени файла, ключа кэша или исходящего адреса из него не строится.
func (Binding) Paths ¶
Paths возвращает пути привязки.
Всякий, кому нужен перечень путей публикации — проба замка «только внутри», композиционный корень, страница развёртывания, — ВЫВОДИТ его отсюда. Выписанный перечень разошёлся бы с привязкой молча при появлении третьей записи, и утверждение о единственном маршруте осталось бы зелёным, уедь второй на внешнюю поверхность.
type Config ¶
type Config struct {
// UpstreamURL — the Hydra PUBLIC JWKS URL to mirror (config.ResolveHydraJWKSURL).
UpstreamURL string
// Client — optional injectable http.Client. nil → an internal client WITH a
// per-call Timeout (never http.DefaultClient).
Client *http.Client
// TTL — cache lifetime when the upstream advertises no Cache-Control max-age.
// <=0 → defaultTTL (5m).
TTL time.Duration
// Timeout — per-call upstream-fetch timeout. <=0 → defaultTimeout (5s).
Timeout time.Duration
// Clock — injectable time source (tests). nil → time.Now.
Clock func() time.Time
// Logger — nil → slog.Default().
Logger *slog.Logger
}
Config — inputs for the JWKS-proxy handler.
type Handler ¶
type Handler struct {
// contains filtered or unexported fields
}
Handler — the caching reverse-proxy handler.
func NewHandler ¶
NewHandler builds the JWKS-proxy handler. The default http.Client carries a per-call Timeout (never http.DefaultClient).
func (*Handler) Stats ¶
func (h *Handler) Stats() MirrorStats
Stats returns a snapshot of the counters.
type KeySetConfig ¶
type KeySetConfig struct {
Source KeySetSource
Logger *slog.Logger
}
KeySetConfig — настройка обработчика нашей записи.
type KeySetHandler ¶
type KeySetHandler struct {
// contains filtered or unexported fields
}
KeySetHandler — НАША запись публикуемого набора: ПРОЕКЦИЯ ключницы.
Почему проекция, а не собственный кэш ¶
Порядок «ключ в наборе раньше, чем им подписан первый токен» становится верен ПО ПОСТРОЕНИЮ: опубликованный и подписывающий читаются из одних и тех же строк, и состояния, в котором ключ подписывает, а в ответе его нет, не существует — его не допускает схема.
Собственный кэш вернул бы состояние «строка есть, в ответе нет», и тогда вступление ключа в подпись пришлось бы гейтить на видимость eventually-consistent проекции — запрещённый подтверждающий барьер. С несколькими репликами публикатора «ответ эндпоинта» перестал бы быть одной величиной.
Цена решения названа ¶
Каждый запрос публикации становится чтением своей базы. Величина ограничена сверху числом потребителей, помноженным на частоту их перезапроса, — то есть единицами запросов в минуту, а не функцией от пользовательской нагрузки.
func NewKeySetHandler ¶
func NewKeySetHandler(cfg KeySetConfig) *KeySetHandler
NewKeySetHandler — построитель.
func (*KeySetHandler) ServeHTTP ¶
func (h *KeySetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
func (*KeySetHandler) Stats ¶
func (h *KeySetHandler) Stats() KeySetStats
Stats возвращает величины по каждому исходу.
type KeySetSource ¶
type KeySetSource interface {
PublishedSet(ctx context.Context) ([]domain.PublishedKey, error)
}
KeySetSource — источник НАШЕЙ записи публикуемого набора.
Реализуется ключницей. Порт назван здесь, у вызывающего: публикатор не знает ни про базу, ни про обёртку приватной половины — да и не может знать, потому что тип, которым ключ сюда приезжает, поля приватной половины не имеет вовсе.
type KeySetStats ¶
KeySetStats — величины по каждому исходу.
«Ноль отказов за всё время жизни» обязано быть отличимо от «контроль не исполнялся», поэтому величины читаются всегда, включая нулевые.
type MirrorStats ¶
type MirrorStats struct {
// Served — fetches that produced a keyset (a cache hit serves without a fetch
// and is not counted here; this counts what came off the hop).
Served uint64
Unavailable uint64
// Misconfigured — fetches refused because the address does not serve a JWKS.
Misconfigured uint64
}
MirrorStats — what this mirror has actually done since the process started.
It exists to answer "has this control ever refused anything, and for which reason?". Without the counts, a mirror that has fail-closed on every fetch since the stand came up is indistinguishable from one that never had to — and the difference is "every docker pull is 401" versus "everything is fine".
WHO READS THIS. The composition root registers a scrape collector over these counters (kaname_jwks_mirror_outcomes_total), so Served is published beside the two refusal reasons and "never refused" stays distinguishable from "never reached". Здесь стояло «процесс сообщает их в журнале жизненного цикла» — такого читателя в дереве не было НИ ОДНОГО, то есть комментарий описывал наблюдаемость, которой не существовало, и именно поэтому её отсутствие никого не смутило. Свойство «читатель есть» держит гейт по дереву TestDeclaredAccumulatorsHaveANonTestReader.
type Record ¶
type Record struct {
// Issuer — принимаемый издатель. Ключ поиска, и ТОЛЬКО ключ поиска.
Issuer string
// Path — путь записи. ОБЪЯВЛЯЕТСЯ здесь, а не выводится из издателя.
//
// Производная конструкция «взять базовый адрес и приклеить издателя»
// короче и запрещена по двум причинам сразу. Первая — про безопасность:
// издатель приходит от предъявителя, то есть это недоверенный вход, и
// значению от предъявителя не место в построении пути. Вторая — про
// проверяемость: производный путь получается У ВСЯКОГО издателя, поэтому
// состояние «записи источника нет» не наступает никогда, и страж старта
// становится тождественно истинным — проверка остаётся в тексте, не имея
// возможности упасть.
Path string
// Handler — обработчик записи: проекция ключницы для нашей, зеркало для
// прежнего издателя.
Handler http.Handler
}
Record — одна запись привязки «издатель → источник набора».
Записей больше одной, и это следствие решения принимать двух издателей: у КАЖДОГО принимаемого издателя своя запись. Объединение наборов рассмотрено и отвергнуто — оно уничтожает ровно ту защиту, ради которой развязка и заводится: ключ одного издателя проверял бы токен, объявляющий другого.