Help us shape the future of Go! Take the 2026 Go Developer Survey

oauthceremony

package
v1.16.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 30, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

Documentation

Overview

Package oauthceremony — церемония OAuth 2.0 платформы: ОДНА объявленная граница между службами и движком авторизации.

Главное правило пакета

НИ ОДИН ЭКСПОРТИРОВАННЫЙ ЭЛЕМЕНТ ЭТОГО ПАКЕТА НЕ НАЗЫВАЕТ ЧУЖОГО ТИПА. Ни подпись функции, ни поле структуры, ни метод интерфейса, ни возвращаемая ошибка. Потребитель, читающий этот пакет, не обязан знать, какой движок внутри, и не может узнать это из его поверхности.

Правило не уговор, а ПРЕДИКАТ: проба TestNoForeignTypeInExportedSurface разбирает исходники пакета деревом и отвергает всякое упоминание чужого пакета в типе экспортированного элемента. Словарь дозволенного — только стандартная библиотека, и он объявлен в самой пробе.

Зачем так

Движок внесён в фундамент поддеревом (`internal/oauth2`, происхождение — в `internal/oauth2/PROVENANCE.md`). Правило языка запрещает потребителю импортировать `internal/`, и это первая половина защиты. Вторая половина — здесь: пакет, стоящий над движком, обязан не протаскивать его типы наружу своими руками. Иначе `internal/` защищал бы от прямого импорта, а этот пакет молча выдавал бы то же самое через свои подписи, и смена движка снова стала бы правкой каждой службы.

Из чего состоит граница

Типы запроса и ответа: AuthorizationRequest, AuthorizationIntent, AuthorizationGrant, AuthorizationResult, TokenRequest, TokenResult, IntrospectionRequest, IntrospectionResult, RevocationRequest.

Отказ: ProtocolError с полем Code типа FailureCode. Случаи различаются ПО ЗНАЧЕНИЮ (`errors.Is(err, oauthceremony.ErrInvalidGrant)`), а не по тексту.

Порты хранения: ClientDirectory, AuthorizationCodeVault, AccessTokenVault, RefreshTokenVault, GrantRevoker и необязательный UnitOfWork. Порт выпуска токена доступа: AccessTokenIssuer. Порт сверки секрета клиента: ClientSecretVerifier — проверочное значение секрета и сравнение постоянного времени принадлежат службе, хешера у церемонии нет, а отказ неизвестному клиенту неотличим от отказа неверному секрету ни текстом, ни числом вызовов порта. Все они объявлены здесь и реализуются СЛУЖБОЙ; фундамент не приносит ни одной реализации. Привязка кода к доказательству владения ключом (PKCE, RFC 7636) — поля записи кода (AuthorizationCodeRecord), а не отдельное хранилище: обязательна всякому клиенту, метод — только S256. Порт отзыва получает причину из закрытого словаря RevocationReason — повтор кода, повтор токена обновления, отзыв клиентом, — и служба сопрягает её со своим словарём.

Контекст входа — сессия, уровень (`acr`) и момент (`auth_time`) аутентификации — служба называет полями решения о выдаче (AuthorizationGrant.SessionID, ACR, AuthTime), а не ключами карты Claims. Это снимок на выдаче кода: запись сеанса (SessionRecord) несёт его через обмен кода и каждый оборот без изменения, порт выпуска получает его в гранте, интроспекция называет его в ответе. Словарь уровня закрыт и один на платформу — ранжирование acrlevel; выдача без годного контекста — отказ до кода, запись хранилища без него — нарушение контракта порта.

Идентификатор гранта — ключ семейства — тоже чеканит служба: крючок Config.NewGrantID зовётся ровно один раз на грант, при выдаче кода, и движок своего идентификатора не чеканит ни на одном пути церемонии. Запись хранилища без идентификатора — нарушение контракта порта.

Чего здесь НЕТ и почему

Нет реализации хранилища: она принадлежит службе, владеющей базой. Нет сетевых обработчиков: церемония возвращает то, что следует написать, но ничего не пишет — поверхность может быть HTTP, может быть шлюзом, может быть пробой.

Нет утверждений клиента (RFC 7523 §2.2) и объектов запроса OpenID Connect: им нужен набор ключей клиента типом библиотеки JOSE движка, а она заключена в поддерево (гейт `internal/engineconfinement`); клиент доказывает себя секретом либо, будучи публичным, не доказывает себя вовсе, и PKCE несёт всякий. Нет вида гранта `client_credentials`: у машинных клиентов службы доступа своя выдача.

Нет неявного потока (`response_type=token`): OAuth 2.1 его исключает. Нет способа `urn:ietf:params:oauth:grant-type:jwt-bearer`: он требует своего порта ключей доверенного выпускающего, и слово без обработчика обещало бы несуществующее поведение. Нет выдачи токена личности (OpenID Connect): это отдельный состав портов и отдельные настройки ключей.

Артефакты и подписной материал

Подписного материала у церемонии нет: ни ключа, ни общего секрета, ни библиотеки подписи. Код авторизации и токен обновления НЕПРОЗРАЧНЫ — 256 случайных бит, а в хранилище лежит подпись значения, sha256 в hex; сверяются они только поиском по подписи. Токен доступа выпускает и опознаёт порт службы (AccessTokenIssuer) — тот, кто публикует ключи его проверки; грант лежит под его jti, а срок в ответе обмена — срок этого выпуска. Предикаты — пробы `opaque_artifact_test.go` и `access_issuer_test.go`.

Приставка выпускающего в артефактах

Стратегия выпуска движка, назначенная у него по умолчанию, ШТАМПУЕТ В ТЕКСТ КАЖДОГО ТОКЕНА приставку с именем поставщика (`ory_at_`, `ory_rt_`, `ory_ac_`). Это привязка в самом видном месте из возможных: приставка уезжает клиенту, попадает в его журналы и в его код разбора, и снять её потом нельзя, не сломав всех, кто на неё смотрит.

Церемония выпускает артефакты СВОЕЙ стратегией, без приставки, а значение токена доступа отдаёт таким, каким его выпустил порт. Предикат — проба TestIssuedArtifactsCarryNoVendorPrefix.

Своей приставки пакет тоже не ставит: приставки платформы — предмет пакета `ids`, а не поставщика движка, и заводить их здесь значило бы завести второе место об одном предмете.

Атомарность

Движок читает код авторизации СНАРУЖИ транзакции (замер — в доке AuthorizationCodeVault), а церемония гасит его РОВНО ОДИН РАЗ за обмен — при предъявлении, до сверки доказательства. Значит защита от повторного использования кода целиком на нашей стороне, и она вынесена в контракт порта: тип StoreOutcome не даёт вернуть исход записи, не назвав число затронутых строк.

Повтор кода и токена обновления

Токен обновления, предъявленный после оборота, — повтор (RFC 9700 §4.14.2), и он отзывает СЕМЕЙСТВО гранта целиком: прежний токен доступа и пару, выданную оборотом. Повтор замечается двумя путями — выборкой, отдавшей токен обёрнутым (ErrRefreshTokenRotated из RefreshTokenVault.FetchRefreshToken), и нулём строк оборота, когда выборку прошли двое.

Код авторизации, предъявленный второй раз, — тоже повтор (RFC 6749 §4.1.2), и полоса кода устроена так же: семейство отзывается вместе с парой, которую получил опередивший. Путей два — выборка погашенного кода (ErrAuthorizationCodeConsumed) и ноль строк погашения. Код гасит первое же предъявление, и предъявление с неверным доказательством — тоже: второе предъявление такого кода — повтор.

На обеих полосах отзыв исполняет церемония по завершении операции, вне единицы работы движка, и отвечает отдельным случаем (на проводе — `invalid_grant`); отказ отзыва возвращается отказом операции, а не этим случаем. Предикат — пробы `refresh_replay_test.go` и `code_replay_test.go`, утверждающие СОСТОЯНИЕ гранта после отказа, в том числе при отказавшем порте отзыва.

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrInvalidRequest            = sentinel(CodeInvalidRequest)
	ErrInvalidClient             = sentinel(CodeInvalidClient)
	ErrInvalidGrant              = sentinel(CodeInvalidGrant)
	ErrUnauthorizedClient        = sentinel(CodeUnauthorizedClient)
	ErrUnsupportedGrantType      = sentinel(CodeUnsupportedGrantType)
	ErrUnsupportedResponseType   = sentinel(CodeUnsupportedResponseType)
	ErrUnsupportedResponseMode   = sentinel(CodeUnsupportedResponseMode)
	ErrInvalidScope              = sentinel(CodeInvalidScope)
	ErrAccessDenied              = sentinel(CodeAccessDenied)
	ErrServerError               = sentinel(CodeServerError)
	ErrTemporarilyUnavailable    = sentinel(CodeTemporarilyUnavailable)
	ErrInvalidState              = sentinel(CodeInvalidState)
	ErrInsufficientEntropy       = sentinel(CodeInsufficientEntropy)
	ErrMisconfiguration          = sentinel(CodeMisconfiguration)
	ErrNotFound                  = sentinel(CodeNotFound)
	ErrRequestUnauthorized       = sentinel(CodeRequestUnauthorized)
	ErrRequestForbidden          = sentinel(CodeRequestForbidden)
	ErrTokenExpired              = sentinel(CodeTokenExpired)
	ErrTokenSignatureMismatch    = sentinel(CodeTokenSignatureMismatch)
	ErrInvalidTokenFormat        = sentinel(CodeInvalidTokenFormat)
	ErrTokenClaim                = sentinel(CodeTokenClaim)
	ErrScopeNotGranted           = sentinel(CodeScopeNotGranted)
	ErrInactiveToken             = sentinel(CodeInactiveToken)
	ErrLoginRequired             = sentinel(CodeLoginRequired)
	ErrConsentRequired           = sentinel(CodeConsentRequired)
	ErrInteractionRequired       = sentinel(CodeInteractionRequired)
	ErrRequestNotSupported       = sentinel(CodeRequestNotSupported)
	ErrRequestURINotSupported    = sentinel(CodeRequestURINotSupported)
	ErrRegistrationNotSupported  = sentinel(CodeRegistrationNotSupported)
	ErrInvalidRequestURI         = sentinel(CodeInvalidRequestURI)
	ErrInvalidRequestObject      = sentinel(CodeInvalidRequestObject)
	ErrStorageConflict           = sentinel(CodeStorageConflict)
	ErrAuthorizationCodeConsumed = sentinel(CodeAuthorizationCodeConsumed)
	ErrUnhandledRequest          = sentinel(CodeUnhandledRequest)
	ErrGrantNotFound             = sentinel(CodeGrantNotFound)
	ErrRefreshTokenRotated       = sentinel(CodeRefreshTokenRotated)
	ErrPortContract              = sentinel(CodePortContract)
	ErrPortDeadline              = sentinel(CodePortDeadline)
	ErrPortCanceled              = sentinel(CodePortCanceled)
	ErrCeremonyMisuse            = sentinel(CodeCeremonyMisuse)
	ErrUnknown                   = sentinel(CodeUnknown)
)

Часовые. По одному на КАЖДЫЙ объявленный случай — перечень полон, и его полнота держится пробой TestEverySentinelHasACode, а не внимательностью.

Часовой ИММУТАБЕЛЕН по уговору: его поля не меняются никем и никогда. Обогащённый отказ строится конструктором, а не правкой часового.

Functions

This section is empty.

Types

type AccessTokenIssuer

type AccessTokenIssuer interface {
	// IssueAccessToken выпускает токен доступа по гранту.
	//
	// # Что порт получает
	//
	// Грант — тот же, что церемония положит под jti этого выпуска
	// (AccessTokenVault.StoreAccessToken), очищенный тем же перечнем полей.
	// Отличие одно: срок токена доступа в сеансе у порта — граница (см. ниже),
	// а у записи — exp этого выпуска. Предъявленных секретов — кода,
	// `code_verifier`, токена обновления, секрета клиента — в гранте нет.
	//
	// # Что идёт в токен
	//
	// Утверждения о доступе берутся ТОЛЬКО из того, что назначили согласие и
	// церемония: субъект — grant.Session.Subject, клиент — grant.ClientID,
	// области и получатели — grant.GrantedScopes и grant.GrantedAudiences.
	// grant.RequestedScopes и grant.RequestedAudiences — то, что клиент ПРОСИЛ
	// в запросе авторизации, а не то, что дало согласие; в токен они не идут.
	// grant.Form — протокольные поля запроса, а не утверждения; тоже не идут.
	//
	// Контекст входа — grant.Session.SessionID, grant.Session.ACR и
	// grant.Session.AuthTime: сессия, уровень (`acr`) и момент (`auth_time`)
	// аутентификации, снятые на выдаче кода. У всего семейства они одни —
	// обмен и оборот их не пересчитывают, — и токен, несущий `acr` или
	// `auth_time`, берёт их ОТСЮДА: карта grant.Session.Claims ключей
	// контекста входа не несёт (церемония отвергает их при выдаче), и второго
	// источника у этих значений нет.
	//
	// Токен несёт КЛЮЧ СЕМЕЙСТВА — grant.GrantID. Проверка предъявленного
	// токена сверяет по нему отзыв семейства (см. GrantRevoker, «Отзыв
	// действует и там, где токен доступа ПРЕДЪЯВЛЯЮТ»): токен без ключа
	// семейства отзыв пережил бы до своего exp.
	//
	// # Срок и момент выпуска
	//
	// Срок назначает церемония: grant.Session.ExpiresAt[TokenKindAccess] —
	// граница, позже которой токен истечь НЕ ВПРАВЕ (срок из
	// Config.AccessTokenLifespan от мига обмена, сжатый границей семейства).
	// Выпуск раньше неё законен: подписант режет срок до своего потолка или
	// до целых секунд. Позже неё — ErrPortContract, и выпуск клиенту не
	// уезжает.
	//
	// Момент выпуска — не раньше начала секунды, в которой церемония позвала
	// порт: iat в целых секундах законен, iat раньше этой секунды —
	// ErrPortContract. Срок в ответе обмена — ExpiresAt − IssuedAt, и момент
	// выпуска раньше вызова удлинил бы его сверх того, что токену осталось
	// жить.
	//
	// Исход — IssuedAccessToken, у которого названо всё: значение, jti,
	// момент выпуска и exp, причём exp позже момента выпуска. Под jti
	// церемония кладёт грант (AccessTokenVault.StoreAccessToken), а срок в
	// ответе обмена считает как ExpiresAt − IssuedAt — те же exp и iat, что
	// лежат в самом токене; незаполненное поле — ErrPortContract.
	IssueAccessToken(ctx context.Context, grant GrantRecord) (IssuedAccessToken, error)

	// IdentifyAccessToken проверяет предъявленный токен и отдаёт его jti.
	//
	// Проверяет ТОЛЬКО ПОДЛИННОСТЬ: подпись ключом издателя и форму. Срок
	// порт НЕ судит: его судит церемония по записи гранта. Исходов три, и все
	// три названы:
	//
	//  1. Токен выпущен этим издателем → (jti, nil) — в том числе ИСТЁКШИЙ и
	//     в том числе токен отозванного семейства. Истёкший церемония называет
	//     негодным по записи гранта (`active: false`, RFC 7662 §2.2), а отзыв
	//     по нему снимает семейство (RFC 7009 §2.1) — так же, как по живому.
	//     Пустой jti при nil — ErrPortContract.
	//  2. Токен не его: чужая подпись, неизвестный ключ, не та форма →
	//     ("", ErrGrantNotFound). На интроспекции это `active: false`, на
	//     отзыве — успех без действия (RFC 7662 §2.2, RFC 7009 §2.2).
	//  3. Проверить не удалось: сбой, недоступность, срок или отмена вызова →
	//     отказ ОПЕРАЦИИ. Опознание, которое не состоялось, не вправе стать ни
	//     «негоден», ни «отозвано»: отзыв ответил бы успехом, не сняв живой
	//     токен.
	//
	// Всякий иной названный портом случай — вердикт о токене вместо jti:
	// ErrTokenExpired, ErrInactiveToken, ErrTokenSignatureMismatch и прочие.
	// Это ErrPortContract, и операция отказывает, а не отвечает: прочитанный
	// как «не наш», вердикт «истёк» сделал бы отзыв по истёкшему токену
	// доступа успехом, оставив живым токен обновления его семейства.
	//
	// Грант ищется ТОЛЬКО по опознанному jti. Поэтому значение, jti которого
	// взято из чужого токена, а подпись не сходится, гранта не находит.
	//
	// # Значение token — предъявительский секрет
	//
	// Порт получает предъявленное значение как есть: токен доступа, а на
	// интроспекции и отзыве без подсказки вида — и токен обновления. Порт НЕ
	// пишет его в журнал и НЕ кладёт в текст своего отказа — ни целиком, ни
	// частью. Церемония со своей стороны вырезает предъявленное значение из
	// каждого текста отказа опознания (описание, подсказка, подробности), но
	// отказ порта остаётся причиной отказа (Unwrap) таким, каким порт его
	// вернул.
	IdentifyAccessToken(ctx context.Context, token string) (string, error)
}

AccessTokenIssuer — выпуск и опознание токена доступа.

Реализует СЛУЖБА — своим подписантом токенов. Подписного материала у церемонии нет: ни ключа, ни общего секрета, ни библиотеки подписи. Токен доступа подписывает тот, кто публикует ключи его проверки, и его предъявления сверяют по тем же ключам; второй подписант с отдельным секретом был бы вторым материалом, который служба обязана хранить, вращать и беречь.

Код авторизации и токен обновления этот порт НЕ выпускает: они непрозрачны, их значение — случайные байты церемонии, а в хранилище лежит подпись значения (см. AuthorizationCodeVault, RefreshTokenVault).

Срок каждого вызова — Config.PortTimeout, тот же, что у портов хранения.

type AccessTokenVault

type AccessTokenVault interface {
	// StoreAccessToken кладёт грант под подписью токена доступа — его jti,
	// названным при выпуске (AccessTokenIssuer.IssueAccessToken). Ожидается
	// ровно одна затронутая строка.
	StoreAccessToken(ctx context.Context, signature string, grant GrantRecord) (StoreOutcome, error)

	// FetchAccessToken отдаёт грант по подписи. Нет → ErrGrantNotFound.
	// Токен отозванного семейства (см. GrantRevoker) — тоже ErrGrantNotFound,
	// когда бы он ни был положен.
	FetchAccessToken(ctx context.Context, signature string) (GrantRecord, error)

	// DropAccessToken снимает токен доступа по подписи.
	//
	// Затронуто 0 строк — ЗАКОННЫЙ исход, а не отказ: снятие
	// несуществующего артефакта идемпотентно по RFC 7009 §2.2, и служба
	// не обязана знать, кто снял его первым. Больше одной строки →
	// ErrPortContract.
	DropAccessToken(ctx context.Context, signature string) (StoreOutcome, error)
}

AccessTokenVault — хранилище токенов доступа.

Реализует СЛУЖБА.

type AuthorizationCodeRecord

type AuthorizationCodeRecord struct {
	// Grant — грант, выданный кодом.
	Grant GrantRecord

	// ProofKey — привязка кода к доказательству владения ключом.
	ProofKey ProofKeyBinding
}

AuthorizationCodeRecord — запись кода авторизации в том виде, в каком она лежит в хранилище службы: грант и привязка кода к доказательству владения ключом.

Почему одна запись

Код и его привязка рождаются одной выдачей и умирают одним погашением, и у службы это одна строка (вызов и метод — её столбцы). Отдельное хранилище привязки под той же подписью было бы второй записью об одном коде и вторым ходом там, где база держит одну строку.

type AuthorizationCodeVault

type AuthorizationCodeVault interface {
	// StoreAuthorizationCode кладёт запись кода под его подписью — sha256
	// значения кода в hex, 64 знака. Само значение сюда не приходит: код
	// непрозрачен, и строка хранилища его не выдаёт.
	//
	// Подпись уникальна: ожидается INSERT, затронувший ровно одну строку.
	// Затронуто 0 → ErrPortContract (вставка, ничего не вставившая, —
	// дефект). Затронуто больше 1 → ErrPortContract (подпись не
	// уникальна). Столкновение подписей → ErrStorageConflict.
	//
	// Привязка в записи ВСЕГДА годна (метод S256, вызов — свёртка в
	// base64url): запрос без неё отвергается до выдачи, и служба вправе
	// держать оба столбца обязательными.
	StoreAuthorizationCode(ctx context.Context, signature string, code AuthorizationCodeRecord) (StoreOutcome, error)

	// FetchAuthorizationCode отдаёт запись кода по его подписи.
	//
	// Исходов ТРИ, и все три названы:
	//
	//  1. Код есть и не погашен → (запись, nil).
	//  2. Кода нет вовсе → (пустая запись, ErrGrantNotFound).
	//  3. Код есть и УЖЕ ПОГАШЕН → (ЗАПИСЬ, ErrAuthorizationCodeConsumed).
	//
	// Третий исход обязан отдавать запись ВМЕСТЕ с ошибкой. Это не
	// небрежность контракта: повторное предъявление кода отзывает ВСЕ
	// артефакты того гранта (RFC 6749 §4.1.2, замечание о безопасности) —
	// отзыв исполняет церемония по завершении операции, — а для отзыва
	// нужен идентификатор гранта. Грант без идентификатора при третьем
	// исходе — ErrPortContract: реализация превратила бы обнаруженную атаку
	// в тихий отказ, оставив выданные токены живыми.
	//
	// Третий исход обязан отдаваться, пока может жить семейство гранта, — и
	// после срока самого кода. Повтор узнаётся по записи, а не по сроку:
	// движок сверяет повтор раньше срока, и срока погашенного кода церемония
	// не судит. Запись, снятая по сроку кода, превратила бы повтор после срока
	// во второй исход — «кода нет», — и семейство осталось бы живым. Порог
	// уборки записи поэтому считается от tokenpolicy.MaxRefreshTokenFamilyTTL,
	// а не от tokenpolicy.MaxAuthorizationCodeTTL.
	//
	// Привязка отдаётся ТАКОЙ, КАКОЙ ЛЕГЛА: живой код без годной привязки S256
	// — ErrPortContract (порча записи), а не «PKCE не было».
	FetchAuthorizationCode(ctx context.Context, signature string) (AuthorizationCodeRecord, error)

	// ConsumeAuthorizationCode гасит код.
	//
	// ОДНОЙ ИНСТРУКЦИЕЙ, с условием внутри неё:
	//
	//	UPDATE authorization_codes
	//	   SET consumed_at = now()
	//	 WHERE signature = $1 AND consumed_at IS NULL
	//
	// и возвратом RowsTouched(tag.RowsAffected()).
	//
	// Исходы по числу строк:
	//   1 → код погашен нами; обмен продолжается;
	//   0 → код уже был погашен — одновременный ПОВТОР: выборку кода прошли
	//       двое, и этот обмен опередили. Церемония отвечает
	//       ErrAuthorizationCodeConsumed и отзывает семейство гранта (через
	//       GrantRevoker, вне единицы работы движка) — вместе с парой,
	//       которую получил опередивший; обмен не состоится;
	//   иное → ErrPortContract: подпись не уникальна либо драйвер не
	//       умеет считать строки.
	//
	// ЗАПРЕЩЕНО: читать, сравнивать в коде и потом писать. Запрещено
	// возвращать StoreOutcome{} — незаполненный исход отвергается как
	// нарушение контракта, а не толкуется как ноль строк.
	ConsumeAuthorizationCode(ctx context.Context, signature string) (StoreOutcome, error)
}

AuthorizationCodeVault — хранилище кодов авторизации.

Реализует СЛУЖБА.

КОД И ЕГО ПРИВЯЗКА PKCE — ОДНА ЗАПИСЬ

Запись кода (AuthorizationCodeRecord) несёт и грант, и привязку кода к доказательству владения ключом (RFC 7636): вызов и метод — её поля, у службы — столбцы той же строки. Отдельного хранилища привязки нет: код и привязка рождаются одной выдачей и умирают одним погашением.

ГДЕ ЗДЕСЬ АТОМАРНОСТЬ И ПОЧЕМУ ОНА ЦЕЛИКОМ НА НАШЕЙ СТОРОНЕ

Измерено по внесённому движку (ветка волны 32 @84b9be8): обработчик кода читает код на строке 33 `internal/oauth2/handler/oauth2/ flow_authorize_code_token.go`; обработчик PKCE затем снимает привязку на строке 150 `internal/oauth2/handler/pkce/handler.go` — ДО сверки доказательства; выдача читает код снова (строка 127), открывает транзакцию (156) и гасит код (168). Чтение стоит СНАРУЖИ транзакции, и между чтением и погашением нет ни блокировки, ни повторной проверки: два одновременных обмена одним кодом оба проходят чтение.

Церемония гасит код РОВНО ОДИН РАЗ за обмен и в первом из этих мест — при снятии привязки, то есть при ПРЕДЪЯВЛЕНИИ кода, до сверки доказательства: предъявление и есть использование кода (RFC 6749 §4.1.2), и код, предъявленный с неверным доказательством, второго предъявления не получает. Погашение на выдаче ту же запись уже не трогает.

Значит защита от повторного использования кода — РОВНО ОДНА инструкция ConsumeAuthorizationCode, и никакая часть её не лежит в движке.

type AuthorizationGrant

type AuthorizationGrant struct {
	// Subject — от чьего имени выдаётся грант. Пусто — отказ.
	Subject string

	// Username — человекочитаемое имя. Необязательно.
	Username string

	// SessionID — сессия входа, из которой служба выдаёт грант. Пусто —
	// отказ: семейство гранта кончается вместе с этой сессией, и без её
	// имени кончить его было бы нечем.
	SessionID string

	// ACR — уровень аутентификации этой сессии (`acr`, RFC 9470 §3). Словарь
	// ЗАКРЫТ и один на платформу — ранжирование acrlevel: годен уровень,
	// который оно ставит выше анонима. Прочее, в том числе пустое значение и
	// "0" (аноним), — отказ: грант выдаётся после входа, а у входа уровень
	// есть.
	ACR string

	// AuthTime — момент аутентификации этой сессии (`auth_time`). Нулевое
	// время — отказ: момента нет.
	AuthTime time.Time

	// GrantedScopes — области, которые служба РЕШИЛА выдать. Выдача может
	// сузить запрос, но не расширить: область, не покрытая запрошенными по
	// правилу Config.ScopeMatching, отвергается ЦЕРЕМОНИЕЙ
	// (ErrCeremonyMisuse) до выпуска кода. Движок выданное не сверяет.
	//
	// Каждая область — `scope-token` (RFC 6749 §3.3,
	// `1*( %x21 / %x23-5B / %x5D-7E )`); иная отвергается так же — до выпуска
	// кода и раньше сверки с запросом: образец `tenant.*` покрыл бы и
	// `tenant.a b`, а клиент прочёл бы её двумя областями.
	GrantedScopes []string

	// GrantedAudiences — получатели, которых служба решила выдать. Тоже не
	// шире запроса: получатель, которого нет среди запрошенных (точное
	// совпадение), отвергается церемонией (ErrCeremonyMisuse).
	GrantedAudiences []string

	// Claims — дополнительные утверждения в сеанс. Ключ контекста входа
	// (`sid`, `acr`, `auth_time`) — отказ (ErrCeremonyMisuse): у значения
	// есть поле выше, и второе место у него разошлось бы с первым.
	Claims map[string]any

	// ExpiresAt — ГРАНИЦА годности по видам артефактов для всего семейства
	// гранта: код, первая пара и каждая пара оборота живут не дольше. Срок
	// каждого артефакта — меньшее из границы и срока из настроек церемонии,
	// отсчитанного от его выпуска. Вид без записи — срок из настроек.
	// Нулевое время — не граница и отвергается церемонией по имени вида
	// (ErrCeremonyMisuse) до выпуска кода: движок читает нулевой срок
	// токена обновления как «без срока», и граница-ноль сделала бы семейство
	// бессрочным. Вид вне словаря TokenKinds отвергается так же: церемония
	// его не выпускает, и граница под ним не ограничила бы ничего.
	ExpiresAt map[TokenKind]time.Time
}

AuthorizationGrant — решение службы о том, что выдать. Приходит ПОСЛЕ того, как человек опознан и согласие получено; церемония ни того, ни другого не исполняет и исполнять не должна.

type AuthorizationIntent

type AuthorizationIntent struct {
	// contains filtered or unexported fields
}

AuthorizationIntent — разобранный и проверенный запрос авторизации, ожидающий решения службы.

Почему у него нет ни одного экспортированного поля

Внутри лежит разобранный запрос ДВИЖКА. Сделай поле экспортированным — и имя движка уехало бы в подпись, а потребитель оказался бы обязан его знать. Наружу смотрят только методы, и все они отвечают нашими типами.

Значение передаётся по значению и годится ровно одной церемонии — той, что его выдала. Чужое намерение отвергается (ErrCeremonyMisuse).

func (AuthorizationIntent) ClientID

func (i AuthorizationIntent) ClientID() string

ClientID — клиент, запросивший авторизацию.

func (AuthorizationIntent) Delivery

Delivery — выбранный способ доставки ответа.

func (AuthorizationIntent) Issued

func (i AuthorizationIntent) Issued() bool

Issued отвечает, выдано ли намерение церемонией. Ложь означает нулевое значение типа.

func (AuthorizationIntent) Parameter

func (i AuthorizationIntent) Parameter(name string) string

Parameter отдаёт протокольное поле запроса по имени: `nonce`, `prompt`, `code_challenge` и прочие, которые служба показывает человеку или кладёт в свой журнал согласия.

func (AuthorizationIntent) RedirectURI

func (i AuthorizationIntent) RedirectURI() string

RedirectURI — проверенный адрес возврата. Пусто, если адрес не прошёл проверку: по такому адресу отвечать запрещено (RFC 6749 §4.1.2.1).

func (AuthorizationIntent) RequestedAudiences

func (i AuthorizationIntent) RequestedAudiences() []string

RequestedAudiences — запрошенные получатели.

func (AuthorizationIntent) RequestedScopes

func (i AuthorizationIntent) RequestedScopes() []string

RequestedScopes — запрошенные области.

func (AuthorizationIntent) ResponseKinds

func (i AuthorizationIntent) ResponseKinds() []ResponseKind

ResponseKinds — запрошенные типы ответа.

func (AuthorizationIntent) State

func (i AuthorizationIntent) State() string

State — непрозрачное значение клиента.

type AuthorizationRequest

type AuthorizationRequest struct {
	// ClientID — `client_id`.
	ClientID string

	// RedirectURI — `redirect_uri`. Пусто означает «взять единственный
	// зарегистрированный»; при нескольких зарегистрированных пустое
	// значение — отказ, и его выносит движок.
	RedirectURI string

	// ResponseKinds — `response_type`, разобранный по пробелу.
	ResponseKinds []ResponseKind

	// Scopes — `scope`, разобранный по пробелу. Область вне грамматики
	// `scope-token` (RFC 6749 §3.3) Authorize отвергает случаем
	// CodeInvalidScope и возвращает намерение, годное для DenyAuthorization.
	Scopes []string

	// Audiences — `audience`.
	Audiences []string

	// State — `state`. Непрозрачное значение клиента против CSRF.
	State string

	// Delivery — `response_mode`.
	Delivery ResponseDelivery

	// Additional — прочие протокольные поля: `code_challenge`,
	// `code_challenge_method`, `nonce`, `prompt`, `request_uri`, поля
	// расширений. Дублировать именованные поля запрещено.
	Additional map[string][]string
}

AuthorizationRequest — запрос к точке авторизации в наших терминах.

Почему поля, а не сырая карта

Сырая карта сделала бы контракт нечитаемым, а опечатку в имени поля — невидимой. Поэтому поля RFC названы, а всё прочее живёт в Additional.

Почему значение выразимо ровно одним способом

Ключ, дублирующий именованное поле, в Additional ЗАПРЕЩЁН и даёт ErrCeremonyMisuse. Иначе у одного значения было бы два места, и они разошлись бы — молча и в сторону меньшей строгости.

type AuthorizationResult

type AuthorizationResult struct {
	// Status — состояние ответа HTTP, которое следует выставить.
	Status int

	// Headers — заголовки, которые следует выставить. Для доставки
	// query и fragment сюда входит Location.
	Headers map[string][]string

	// Body — тело ответа. Непусто только для доставки form_post: там
	// ответ — самоотправляющаяся форма HTML, и собирает её движок.
	Body []byte

	// RedirectURI — адрес возврата целиком, включая параметры. Пусто для
	// доставки form_post.
	RedirectURI string

	// Parameters — параметры ответа, разобранные из адреса возврата:
	// `code`, `state`, `scope`, либо `error`+`error_description` на
	// пути отказа.
	Parameters map[string][]string

	// Delivery — способ доставки, которым ответ собран. Никогда не
	// DeliveryDefault: к этому мигу выбор уже сделан.
	Delivery ResponseDelivery
}

AuthorizationResult — ответ точки авторизации, собранный ДВИЖКОМ по RFC и переложенный в наши термины.

Церемония НЕ ПИШЕТ в сеть: она возвращает то, что следует написать. Кто и как это отправит — дело поверхности.

type Ceremony

type Ceremony struct {
	// contains filtered or unexported fields
}

Ceremony — церемония OAuth 2.0 платформы.

Значение НЕИЗМЕНЯЕМО после New и пригодно для одновременного использования из многих исполнителей: собственного изменяемого состояния у него нет, а методы настроек движка (engine.Config) на пути запроса в настройки не пишут — в тех пределах, в каких это держат пробы. Две пробы держат каждая свою часть:

  • TestEngineSettingsBuiltByNewAreOnlyReadOnTheRequestPath вызывает каждый метод настроек, собранных New, и ловит ЗАМЕНУ значения поля — ленивое заполнение пустого поля в том числе;
  • TestEngineSettingsMethodsWriteNoFieldContent разбирает исходник методов настроек и ловит запись в СОДЕРЖИМОЕ поля — элемент карты или среза, значение под указателем, — которой первая проба не видит, но только в тех формах записи, которые разбор знает. Это ограждение известных форм, а не доказательство отсутствия записи: разбор без типов полным не бывает, и держимые им формы перечислены в шапке engineconfig_static_internal_test.go.

Чего не судит ни одна из них: запись в поле того же значения, что там уже лежит; запись в содержимое в формах слепой зоны разбора (перечень blindZoneForms, не замкнутый) — замыкание, отдающее содержимое; часть локального значения, получившая содержимое присваиванием; канал, через который прошло содержимое; получатель отданного содержимого, то есть код движка, пишущий в то, что вернул ему геттер; прочее состояние движка — обработчики, стратегию выпуска артефактов. У форм слепой зоны держателя нет ни на одном пути запроса: статическая проба на них молчит — её ноль находок и неразобранных стоит и при такой записи в дереве (это утверждает TestNamedBlindZoneFormsStaySilent), — а перепись по тождеству содержимого не видит. -race проба TestConcurrentExchangesOnAFreshCeremonyShareNoEngineState судит не форму записи, а только гонку: два доступа к одному месту из одновременных обменов, хотя бы один из них запись, не упорядоченные синхронизацией, — запись против чтения и запись против записи, — на пути обмена и лишь когда они случились в её прогоне. Исполненная её прогоном запись этим ещё не поймана: запись, которую ни один другой обмен не делит с ней без синхронизации, для детектора не гонка, сколько бы раз она ни исполнилась.

func New

func New(cfg Config, ports Ports) (*Ceremony, error)

New собирает церемонию.

Отвергает всё, что не названо, ДО первого запроса: проверка настроек в рабочем пути означала бы, что негодная сборка живёт до первого обращения и падает на пользователе.

func (*Ceremony) Authorize

Authorize разбирает и проверяет запрос авторизации.

Что он НЕ делает

Не опознаёт человека, не спрашивает согласия, не пишет в сеть. Он отвечает намерением, которое служба показывает человеку и затем закрывает либо CompleteAuthorization, либо DenyAuthorization.

Почему намерение возвращается И ПРИ ОТКАЗЕ

Отказ точки авторизации по RFC 6749 §4.1.2.1 уезжает клиенту ПЕРЕНАПРАВЛЕНИЕМ на его адрес возврата, а не телом ответа. Чтобы собрать перенаправление, нужен разобранный запрос — тот самый, при разборе которого случился отказ. Поэтому первый исход годен для DenyAuthorization даже тогда, когда второй — непустая ошибка; если же адрес возврата не прошёл проверку, RedirectURI пуст, и отвечать клиенту запрещено.

func (*Ceremony) CompleteAuthorization

func (c *Ceremony) CompleteAuthorization(ctx context.Context, intent AuthorizationIntent, grant AuthorizationGrant) (AuthorizationResult, error)

CompleteAuthorization закрывает намерение выдачей.

Собирает ответ РУКАМИ ДВИЖКА — тем же кодом, что собирает его у всех, кто движок применяет напрямую. Собрать перенаправление здесь заново значило бы завести второе место об одном предмете, и оно разошлось бы с первым на первом же расширении (доставка form_post, порядок параметров, кодирование фрагмента).

func (*Ceremony) DenyAuthorization

func (c *Ceremony) DenyAuthorization(ctx context.Context, intent AuthorizationIntent, reason error) (AuthorizationResult, error)

DenyAuthorization закрывает намерение отказом.

reason обязан быть НАШЕЙ ошибкой: перевод в отказ движка идёт по значению случая, а не по тексту. Чужая ошибка уезжает как «внутренняя ошибка сервера» — и это честный исход, а не молчание.

func (*Ceremony) Exchange

func (c *Ceremony) Exchange(ctx context.Context, req TokenRequest) (TokenResult, error)

Exchange обменивает грант на артефакты (RFC 6749 §4.1.3, §6).

func (*Ceremony) Introspect

Introspect отвечает, годен ли предъявленный артефакт (RFC 7662).

Почему «негоден» — НЕ ошибка

RFC 7662 §2.2 требует отвечать `active: false` и на выдуманный артефакт, и на погашенный, и на чужой. Верни мы здесь ошибку — вызывающий был бы вынужден отличать «негоден» от «хранилище упало» по тексту, а поверхность научилась бы отвечать разными кодами HTTP на годный и негодный артефакт, то есть стала бы прибором для перебора.

func (*Ceremony) Revoke

func (c *Ceremony) Revoke(ctx context.Context, req RevocationRequest) error

Revoke снимает артефакт и всё, что выдано по тому же гранту (RFC 7009).

Почему «нечего снимать» — НЕ ошибка

RFC 7009 §2.2 требует отвечать успехом на отзыв артефакта, которого нет: иначе отзыв стал бы прибором для проверки существования артефакта.

type ClientAuthMethod

type ClientAuthMethod string

ClientAuthMethod — способ, которым клиент доказывает себя точке токена (RFC 6749 §2.3).

Перечень ЗАКРЫТ: секрет либо ничего. Утверждения клиента (RFC 7523 §2.2) церемония не обслуживает — ни способом здесь, ни полем в Additional: движок отвергает такое утверждение (см. ClientRegistration).

const (
	// ClientAuthNone — публичный клиент, секрета нет.
	ClientAuthNone ClientAuthMethod = "none"
	// ClientAuthBasic — секрет в заголовке Authorization: Basic.
	ClientAuthBasic ClientAuthMethod = "client_secret_basic"
	// ClientAuthPost — секрет в теле запроса.
	ClientAuthPost ClientAuthMethod = "client_secret_post"
)

Способы доказательства клиента.

func ClientAuthMethods

func ClientAuthMethods() []ClientAuthMethod

ClientAuthMethods возвращает словарь способов целиком — тому, кто строит метаданные сервера обнаружения (`token_endpoint_auth_methods_supported`, RFC 8414 §2) или проверяет запись клиента. Точка токена и отзыв принимают каждый способ словаря; интроспекция — только те, что называет IntrospectionAuthMethods.

func IntrospectionAuthMethods

func IntrospectionAuthMethods() []ClientAuthMethod

IntrospectionAuthMethods — способы, которыми спрашивающий доказывает себя точке интроспекции (`introspection_endpoint_auth_methods_supported`, RFC 8414 §2). Перечень уже словаря ClientAuthMethods: движок на этой точке берёт доказательство только заголовком Authorization (RFC 7662 §2.1), и ни секрета в теле запроса, ни запроса без доказательства не принимает. Способ вне перечня Introspect отвергает по имени до движка (ErrCeremonyMisuse).

type ClientDirectory

type ClientDirectory interface {
	// LookupClient отдаёт запись клиента по его публичному
	// идентификатору.
	//
	// Клиента нет → ErrGrantNotFound. Отдавать пустую запись без ошибки
	// запрещено: «клиента нет» и «клиент есть, но пуст» — разные события.
	//
	// ErrGrantNotFound — ЕДИНСТВЕННЫЙ случай пакета в контракте этого
	// вызова. Любой другой наш случай церемония читает как нарушение
	// контракта (ErrPortContract), а не как исход протокола; прочий отказ —
	// отказ операции (срок и отмена — своими случаями).
	//
	// В операции, где клиент доказывает себя (обмен, интроспекция, отзыв),
	// «клиента нет» доказательства не обрывает: церемония всё равно зовёт
	// порт сверки (ClientSecretVerifier) — ровно один раз, как для известного
	// клиента, — и отвечает тем же отказом, что неверному секрету.
	LookupClient(ctx context.Context, clientID string) (ClientRegistration, error)
}

ClientDirectory — откуда берутся записи клиентов.

Реализует СЛУЖБА. Ожидается чтение из таблицы клиентов с кэшем на стороне службы; церемония не кэширует ничего — отзыв клиента обязан действовать немедленно.

type ClientRegistration

type ClientRegistration struct {
	// ClientID — публичный идентификатор клиента.
	ClientID string

	// RedirectURIs — точный перечень разрешённых адресов возврата.
	RedirectURIs []string

	// GrantKinds — способы получения токена, разрешённые клиенту.
	GrantKinds []GrantKind

	// ResponseKinds — разрешённые СОЧЕТАНИЯ типов ответа. Каждый элемент —
	// одно сочетание; составное записывается через пробел (RFC 6749 §3.1.1).
	// Церемония обслуживает одно сочетание — `code` (ResponseKindCode).
	ResponseKinds []string

	// Scopes — области, которые клиенту дозволено запрашивать.
	Scopes []string

	// Audiences — получатели, которых клиенту дозволено запрашивать.
	Audiences []string

	// Public — клиент не может хранить секрет (приложение в браузере,
	// мобильное приложение). PKCE церемония требует от всякого клиента, и
	// публичного, и конфиденциального (см. ProofKeyBinding).
	Public bool

	// ResponseDeliveries — разрешённые способы доставки ответа. Пустой
	// перечень означает «только тот, что движок выберет по умолчанию».
	ResponseDeliveries []ResponseDelivery
}

ClientRegistration — запись клиента в НАШИХ терминах: данные, а не поведение.

Почему структура, а не интерфейс

Движок объявляет клиента интерфейсом из семи методов и расширяет его ещё тремя интерфейсами, которые он проверяет утверждением типа. Реализуй служба такой интерфейс — и состав её обязанностей менялся бы при обновлении апстрима молча: новый необязательный интерфейс просто перестал бы подхватываться. Структура делает состав ЯВНЫМ: новое поле видно в диффе.

Чего в записи НЕТ

Секрета — ни в каком виде: ни его проверочного значения, ни прежних значений на время оборота. Их держит и сверяет служба портом ClientSecretVerifier; у церемонии нет ни значения, ни хешера.

Набора ключей клиента, адресов объектов запроса и закреплённого способа доказательства. Всё это нужно утверждениям клиента и объектам запроса OpenID Connect, а их церемония не обслуживает: конфиденциальный клиент доказывает себя секретом — заголовком либо телом запроса (RFC 6749 §2.3.1), публичный не доказывает себя вовсе и обязан нести PKCE.

type ClientSecretVerifier

type ClientSecretVerifier interface {
	// VerifyClientSecret сверяет секрет, предъявленный от имени клиента
	// clientID. Идентификатор — такой, каким его назвал клиент: знает ли его
	// служба, порт выясняет сам.
	VerifyClientSecret(ctx context.Context, clientID string, presented PresentedSecret) (SecretVerdict, error)
}

ClientSecretVerifier — сверка секрета клиента.

Реализует СЛУЖБА: она владеет проверочным значением секрета — его функцией, солью, ценой и оборотом — и сравнением постоянного времени. У церемонии нет ни значения, ни хешера: хешер движка на её путях не участвует, и цену сверки назначает служба, а не настройки церемонии.

Когда церемония зовёт порт

РОВНО ОДИН РАЗ на доказательство клиента — в обмене, интроспекции и отзыве, — и для клиента, которого справочник знает, и для клиента, которого он НЕ знает (ClientDirectory ответил ErrGrantNotFound). Отказ неизвестному клиенту обязан стоить столько же, сколько отказ неверному секрету, иначе время ответа — прибор для перебора зарегистрированных клиентов. Церемония держит свою половину — число вызовов и один и тот же отказ; служба свою: для клиента без проверочного значения она сверяет предъявленное с приманкой того же класса и отвечает SecretMismatched.

Публичного клиента (ClientRegistration.Public) точки токена и отзыва не сверяют: он себя не доказывает. Интроспекция требует доказательства от всякого клиента и принимает его только заголовком (RFC 7662 §2.1): запрос без заголовка движок отвергает раньше, чем спросит справочник, и порт не зовётся ни для кого.

Исходы

  • (SecretMatched, nil) — секрет совпал с нынешним проверочным значением клиента либо с прежним на время оборота: оборот секретов — забота службы, и вызов на оба один.
  • (SecretMismatched, nil) — не совпал, не предъявлен (пустое значение) либо клиента у службы нет.
  • (_, ошибка) — сверка не состоялась: сбой, недоступность, срок или отмена вызова. Это отказ ОПЕРАЦИИ, а не «клиент не доказан»: несостоявшаяся сверка не вправе стать ни одним из вердиктов.

Ошибка со случаем этого пакета — ErrPortContract: в контракте сверки нет ни одного такого отказа, и случай порта исходом протокола не становится.

Нулевой вердикт и вердикт вне словаря — ErrPortContract. SecretMatched о клиенте, которого справочник не знает, — тоже ErrPortContract: доказать себя может только зарегистрированный клиент, и порт, сказавший иное, противоречит справочнику.

Секрет

presented — предъявительский секрет. Порт НЕ пишет его в журнал и НЕ кладёт в текст своего отказа; печать типа его не выдаёт (PresentedSecret), значение отдаёт только Reveal.

Срок вызова — Config.PortTimeout, тот же, что у прочих портов.

type Config

type Config struct {
	// AuthorizationEndpoint и TokenEndpoint — адреса точек авторизации и
	// выдачи (RFC 6749 §3.1, §3.2), под которыми служба публикует
	// церемонию, например `https://iam.example.net/iam/v1/authorize` и
	// `https://iam.example.net/iam/v1/token`. Названы явно: путь, на котором
	// служба публикует точки, — её решение, и ни из какого другого поля
	// церемония его не выводит.
	//
	// Адрес точки авторизации — адрес запроса, который церемония подаёт
	// движку от имени Authorize. Адрес точки выдачи — адрес запросов от имени
	// Exchange, Introspect и Revoke (интроспекция и отзыв синтезируются на
	// нём же) и значение TokenURL в настройках движка: с ним движок сверял бы
	// `aud` утверждения клиента (RFC 7523 §3), но на путях церемонии эта
	// сверка не исполняется — утверждение клиента церемония не обслуживает
	// и движок отвергает его раньше (TestClientAssertionIsRefused).
	//
	// Каждый обязан быть абсолютным адресом с именем хоста (порт без имени,
	// `https://:8443/…`, хостом не считается), без сведений пользователя,
	// строки запроса и фрагмента, и записан так, как его получит движок:
	// разбор и обратная запись адреса дают ту же строку. Адрес с пробелом,
	// буквой не-ASCII или схемой в верхнем регистре разбирается, но в запросе к
	// движку уехал бы в другом написании — экранированным или в нижнем
	// регистре, — и адрес, который служба публикует, разошёлся бы с адресом, на
	// который церемония подаёт запросы. Фрагмент адресу точки
	// запрещает сам RFC 6749 (§3.1, §3.2). Строку запроса он разрешает, и
	// здесь она отвергается у обоих адресов: у точки авторизации церемония
	// ставит параметры запроса строкой запроса поверх адреса, и принесённая
	// адресом строка слилась бы с ними; у точки выдачи её пришлось бы
	// сохранять каждому клиенту и в `aud`, — контракт один на оба адреса, и
	// он узкий. Сведения пользователя отвергаются потому, что адрес точки
	// служба публикует клиентам, а учётные данные в публикуемом адресе —
	// секрет в открытом виде; доказательство клиента приезжает заголовком
	// или телом запроса (RFC 6749 §2.3.1), а не адресом.
	AuthorizationEndpoint string
	TokenEndpoint         string

	// AccessTokenLifespan, RefreshTokenLifespan, AuthorizationCodeLifespan
	// — сроки жизни артефактов, каждый от выпуска артефакта. Все три обязаны
	// быть положительными и не длиннее своих потолков фундамента —
	// tokenpolicy.MaxTokenTTL, tokenpolicy.MaxRefreshTokenFamilyTTL и
	// tokenpolicy.MaxAuthorizationCodeTTL. Срок выше потолка New отвергает,
	// называя поле и потолок, а не урезает молча; решение, из которого взято
	// значение каждого потолка, записано у самой константы.
	//
	// У AccessTokenLifespan причина потолка ещё и в выпуске: токен доступа
	// подписывает служба (Ports.AccessTokenIssuer), а её подписант выше
	// потолка платформы не выпускает. Выпуск короче границы законен (контракт
	// порта), поэтому церемония со сроком длиннее обменивала бы исправно, но
	// срок настроек не исполнялся бы ни на одном выпуске — настройка лгала бы
	// молча; отказ при сборке её не допускает.
	//
	// RefreshTokenLifespan — срок ОДНОГО токена обновления: оборот выпускает
	// преемника с тем же сроком от своего выпуска. Предел всего семейства New
	// не держит — его держат граница гранта (AuthorizationGrant.ExpiresAt) и
	// база службы (tokenpolicy.MaxRefreshTokenFamilyTTL называет обоих).
	//
	// Подписного материала в настройках нет: код авторизации и токен
	// обновления непрозрачны (случайные байты, в хранилище — sha256
	// значения), а токен доступа подписывает порт службы.
	AccessTokenLifespan       time.Duration
	RefreshTokenLifespan      time.Duration
	AuthorizationCodeLifespan time.Duration

	// ScopeMatching — правило сопоставления областей.
	ScopeMatching ScopeMatching

	// RefreshTokenIssuance — когда выдавать токен обновления.
	RefreshTokenIssuance RefreshTokenIssuance

	// RefreshTokenScopes — области, дающие токен обновления. Обязателен
	// и непуст при RefreshTokenIssuanceOnScope; обязан быть пуст при
	// RefreshTokenIssuanceAlways, иначе у правила было бы два места.
	RefreshTokenScopes []string

	// MinParameterEntropy — минимальная длина `state` и `nonce` в
	// символах. Обязана быть не меньше 8 (RFC 6749 §10.10 требует не
	// менее 128 бит для непредсказуемых значений; 8 символов — нижняя
	// граница, ниже которой параметр перестаёт быть защитой от CSRF).
	MinParameterEntropy int

	// PortTimeout — срок ОДНОГО вызова порта службы: порта хранения и порта
	// выпуска токена доступа (Ports.AccessTokenIssuer) — и выпуска, и
	// опознания.
	PortTimeout time.Duration

	// OperationTimeout — срок ВСЕЙ операции церемонии. Обязан быть не
	// меньше PortTimeout: иначе первый же вызов порта не уложился бы в
	// операцию, и срок порта не значил бы ничего.
	OperationTimeout time.Duration

	// NewGrantID — крючок чеканки идентификатора гранта. Обязателен.
	//
	// Идентификатор гранта — ключ семейства: по нему служба отзывает все
	// артефакты одной выдачи и под ним ведёт свою запись семейства, форму
	// которой держит ограничение её схемы. Поэтому его выбирает СЛУЖБА:
	// движок, получив запрос без идентификатора, чеканит свой при первом
	// чтении, и ключ, которого служба не выбирала, лёг бы во все её записи.
	// Приставки платформы — предмет пакета `ids`, а не этого.
	//
	// Церемония зовёт крючок ровно один раз на грант — в CompleteAuthorization,
	// после своих проверок выдачи и до выпуска кода — и ставит идентификатор
	// запросу движка раньше, чем тот его прочтёт. Разбор запроса, отказ в
	// согласии, обмен кода, оборот токена обновления, интроспекция и отзыв
	// крючка не зовут: идентификатор едет из записи хранилища.
	//
	// Вызову назначается срок Config.PortTimeout, как вызову порта. Отказ
	// крючка — отказ выдачи (случай разбирается, как отказ порта), и записи
	// кода в хранилище не появляется. Пустой идентификатор — нарушение
	// контракта (ErrPortContract): на пустом движок начеканил бы свой.
	// Уникальность — забота службы: церемония её не сверяет, её держит ключ
	// записи семейства в хранилище службы.
	NewGrantID func(ctx context.Context) (string, error)
}

Config — настройки церемонии.

Ни одно поле не имеет молчаливого умолчания: New отвергает набор, в котором поле не названо, называя поле поимённо. Умолчание, подставленное молча, — решение, принятое за того, кто его не принимал; здесь такие решения касаются сроков жизни токенов и строгости сопоставления областей.

type FailureCode

type FailureCode uint16

FailureCode — ЗНАЧЕНИЕ, по которому вызывающий различает случай отказа.

Почему не строка протокола

На проводе OAuth 2.0 живёт короткий закрытый перечень (`invalid_request`, `invalid_grant`, …), и он НЕ РАЗЛИЧАЕТ то, что нам различать обязательно: «код уже погашен» и «клиент прислал мусор» оба уезжают наружу как `invalid_grant`. Если наш перечень совпадёт с проводным, вызывающему останется различать случаи по ТЕКСТУ подсказки — то есть по тому, что апстрим вправе переписать в любом выпуске.

Поэтому FailureCode — СВОЙ перечень, а проводное представление вычисляется из него методом WireCode. Обратное отображение неоднозначно и не объявляется: из `server_error` нельзя вывести, была ли это поломка порта или нарушение его контракта.

Почему целое, а не строка

Ноль обязан быть НЕ ЗНАЧЕНИЕМ: забытое присваивание обязано выглядеть как «код не назван», а не как один из настоящих случаев. У строкового перечня нулём была бы пустая строка, неотличимая от «сюда ещё не дописали».

const (
	// CodeUnspecified — код не назван. Никогда не возвращается наружу;
	// его появление означает, что ошибка собрана в обход конструктора.
	CodeUnspecified FailureCode = iota

	CodeInvalidRequest
	CodeInvalidClient
	CodeInvalidGrant
	CodeUnauthorizedClient
	CodeUnsupportedGrantType
	CodeUnsupportedResponseType
	CodeUnsupportedResponseMode
	CodeInvalidScope
	CodeAccessDenied
	CodeServerError
	CodeTemporarilyUnavailable
	CodeInvalidState
	CodeInsufficientEntropy
	CodeMisconfiguration
	CodeNotFound
	CodeRequestUnauthorized
	CodeRequestForbidden
	CodeTokenExpired
	CodeTokenSignatureMismatch
	CodeInvalidTokenFormat
	CodeTokenClaim
	CodeScopeNotGranted
	CodeInactiveToken
	CodeLoginRequired
	CodeConsentRequired
	CodeInteractionRequired
	CodeRequestNotSupported
	CodeRequestURINotSupported
	CodeRegistrationNotSupported
	CodeInvalidRequestURI
	CodeInvalidRequestObject
	CodeStorageConflict
	CodeAuthorizationCodeConsumed
	CodeUnhandledRequest

	// CodeGrantNotFound — порт сказал «такой записи нет». Отдельно от
	// CodeInvalidGrant: «нет записи» и «запись есть, но не подходит» —
	// разные события для того, кто читает журнал.
	CodeGrantNotFound

	// CodeRefreshTokenRotated — предъявлен токен обновления, который уже
	// обернули: выборка отдала его обёрнутым, либо оборот затронул ноль
	// строк, потому что его опередили. Это ПОВТОР (RFC 9700 §4.14.2):
	// токеном владеют двое, и церемония, ответив этим случаем, уже отозвала
	// семейство гранта целиком. Отдельно от CodeAuthorizationCodeConsumed:
	// «код погашен» и «токен обновления повторён» — разные артефакты разных
	// потоков, и журналу службы различать их обязательно; на проводе оба —
	// `invalid_grant`.
	CodeRefreshTokenRotated

	// CodePortContract — порт нарушил свой контракт: вернул неназванное
	// число затронутых строк либо число, невозможное при корректной
	// одноинструкционной записи. Это ДЕФЕКТ реализации порта, а не отказ
	// протокола, и он обязан быть отличим от CodeServerError.
	CodePortContract

	// CodePortDeadline — вызов порта не уложился в отведённый срок.
	CodePortDeadline

	// CodePortCanceled — вызов порта снят вызывающим.
	CodePortCanceled

	// CodeCeremonyMisuse — церемонию позвали не так: пустой предмет,
	// намерение не от этой церемонии, взаимоисключающие поля запроса.
	CodeCeremonyMisuse

	// CodeUnknown — движок вернул отказ, которого нет в таблице перевода.
	CodeUnknown
)

Случаи, различаемые по значению. Перечень ЗАКРЫТ: корзины «прочее» с человеческим пояснением здесь нет — есть CodeUnknown, и он означает ровно «движок вернул отказ, которого нет в таблице перевода», то есть ДЕФЕКТ таблицы, который обязан быть виден как таковой.

func CodeOf

func CodeOf(err error) FailureCode

CodeOf достаёт случай из любой ошибки.

Для ошибки не из этого пакета отвечает CodeUnspecified — «код не назван», а НЕ CodeUnknown: CodeUnknown означает провал нашей таблицы перевода, и смешивать его с «эту ошибку сделали не мы» нельзя, иначе дефект таблицы перестанет быть видимым.

func (FailureCode) Declared

func (c FailureCode) Declared() bool

Declared отвечает, назван ли случай. Нужен тому, кто получил FailureCode из сериализованного вида и обязан отвергнуть и чужое число, и CodeUnspecified: «код не назван» — не случай, а признак того, что значение собрано в обход конструктора.

func (FailureCode) HTTPStatus

func (c FailureCode) HTTPStatus() int

HTTPStatus возвращает состояние ответа, приличное этому случаю.

func (FailureCode) String

func (c FailureCode) String() string

String возвращает НАШЕ имя случая. Оно предназначено журналу и метке наблюдаемости, а не проводу.

func (FailureCode) WireCode

func (c FailureCode) WireCode() string

WireCode возвращает значение поля `error` ответа по RFC 6749 §5.2.

Отображение СЖИМАЮЩЕЕ и односторонне: несколько наших случаев уезжают в один проводной код намеренно — наружу не выдаётся то, по чему нападающий отличает «кода нет» от «код уже погашен».

type GrantKind

type GrantKind string

GrantKind — способ получения токена (RFC 6749 §1.3, RFC 7523).

const (
	GrantAuthorizationCode GrantKind = "authorization_code"
	GrantRefreshToken      GrantKind = "refresh_token"
)

Способы, которые церемония обслуживает. Перечень ЗАКРЫТ: значение вне его отвергается в Exchange, а не уезжает в движок в надежде, что он разберётся.

Утверждения по RFC 7523 (`urn:ietf:params:oauth:grant-type:jwt-bearer`) здесь НЕТ, и это решение, а не пропуск: этот способ требует своего порта ключей доверенного выпускающего, которого фундамент пока не объявляет. Объявить слово без обработчика значило бы обещать поведение, которого нет: запрос с таким `grant_type` получал бы «неизвестный запрос» вместо честного «способ не поддерживается». Способ заводится отдельной фазой вместе со своим портом.

`client_credentials` здесь тоже НЕТ: машинные клиенты службы доступа получают токен своей выдачей, а не церемонией (решение по эпику, К3). Слово вне словаря отвергается в Exchange до движка, и обработчика этого вида в провязке церемонии нет.

func GrantKinds

func GrantKinds() []GrantKind

GrantKinds возвращает словарь целиком — тому, кто строит метаданные сервера обнаружения или проверяет запись клиента.

func (GrantKind) Declared

func (g GrantKind) Declared() bool

Declared отвечает, входит ли способ в словарь. Пустое значение НЕ входит: «способ не назван» означало бы «любой».

type GrantRecord

type GrantRecord struct {
	// GrantID — идентификатор гранта. По нему отзываются ВСЕ артефакты
	// одного гранта (RFC 7009 §2.1, замечание о реализации).
	//
	// Его чеканит служба крючком Config.NewGrantID при выдаче кода; записи
	// токенов наследуют его от записи кода. Запись, которую хранилище отдаёт
	// живой, обязана его нести: пустой — нарушение контракта порта
	// (ErrPortContract), а не повод движку начеканить свой.
	GrantID string

	// ClientID — клиент, которому выдан грант.
	ClientID string

	// IssuedAt — когда грант возник.
	IssuedAt time.Time

	// RequestedScopes / GrantedScopes — что просили и что дали. Хранятся
	// ОБА: отказ в области — это разница между ними, и она обязана быть
	// восстановима из записи, а не вычисляема заново.
	//
	// Каждая выданная область — `scope-token` (RFC 6749 §3.3): иной
	// церемония на хранение не отдаёт, и запись из хранилища с такой областью —
	// нарушение контракта порта (ErrPortContract): по ней обмен и оборот
	// выдали бы область заново.
	RequestedScopes []string
	GrantedScopes   []string

	// RequestedAudiences / GrantedAudiences — то же про получателей.
	RequestedAudiences []string
	GrantedAudiences   []string

	// Form — протокольные поля запроса, породившего грант, ОЧИЩЕННЫЕ
	// перечнем полей: всякая запись несёт лишь `grant_type`,
	// `response_type`, `scope` и `client_id`, а сверх них запись кода —
	// `redirect_uri`: по нему при обмене сверяется адрес возврата. Привязки к
	// доказательству владения ключом здесь НЕТ: у кода она — поле его записи
	// (AuthorizationCodeRecord.ProofKey), и второго места у неё нет.
	// Предъявленных секретов — кода, `code_verifier`, токена обновления,
	// секрета клиента — в Form нет ни у одной записи и ни у гранта, который
	// получает порт выпуска токена доступа.
	Form map[string][]string

	// Session — состояние сеанса.
	Session SessionRecord
}

GrantRecord — грант в том виде, в каком он лежит в хранилище службы.

Это ЕДИНСТВЕННАЯ форма, в которой грант пересекает границу порта: ни код авторизации, ни токен обновления не возят с собой ничего, кроме этой записи и своей подписи.

type GrantRevoker

type GrantRevoker interface {
	// RevokeGrantRefreshTokens снимает ВСЕ токены обновления гранта по
	// причине reason. Ноль строк — законный исход (нечего снимать).
	RevokeGrantRefreshTokens(ctx context.Context, grantID string, reason RevocationReason) (StoreOutcome, error)

	// RevokeGrantAccessTokens снимает ВСЕ токены доступа гранта по причине
	// reason. Ноль строк — законный исход.
	RevokeGrantAccessTokens(ctx context.Context, grantID string, reason RevocationReason) (StoreOutcome, error)
}

GrantRevoker — отзыв всех артефактов одного гранта.

Реализует СЛУЖБА.

Отзыв — СЕМЕЙСТВА, и он НЕОБРАТИМ

Отозванный грант не годен целиком: его токен доступа и токен обновления не годны, КОГДА БЫ ОНИ НИ БЫЛИ ПОЛОЖЕНЫ — до отзыва или после. Выборка такого токена отвечает ErrGrantNotFound.

Почему «и после». На одновременном повторе (см. RotateRefreshToken и ConsumeAuthorizationCode) проигравший отзывает семейство, пока победитель ещё кладёт выданную ему пару. Порядок этих записей держит только единица работы, а она необязательна (UnitOfWork). Отзыв, снимающий лишь уже лежащие строки, при таком порядке оставил бы пару победителя живой — ровно ту, ради снятия которой отзыв и исполнялся. Ожидаемая форма — отметка отзыва у самого семейства (`token_families.revoked_at`), которую выборка сверяет, а не удаление строк.

Отзыв действует и там, где токен доступа ПРЕДЪЯВЛЯЮТ

Токен доступа сверяют по ключам его издателя, без этого хранилища (см. AccessTokenIssuer): место, принимающее токен, выборки гранта не делает. Поэтому отзыв семейства ОБЯЗАН дойти и туда: токен доступа отозванного семейства получает отказ при предъявлении в любом месте, которое его принимает, — и раньше своего exp. Ключ семейства в токене — GrantID гранта, который порт выпуска кладёт в токен (IssueAccessToken), и отзыв записывает отсечку по этому ключу там, где её читает проверка предъявленного токена. Отзыв, снимающий только строки и отметку этого хранилища, оставил бы отозванный токен годным везде, где его сверяют по ключам, до конца срока.

Отсечка действует на ВСЯКИЙ токен семейства, КОГДА БЫ ОН НИ БЫЛ ВЫПУЩЕН — до отметки отзыва или после, — а не на выпущенные до неё. Почему «и после»: на одновременном повторе кода опередивший гасит код при предъявлении, то есть раньше своего выпуска (см. AuthorizationCodeVault), и его выпуск может лечь ПОЗЖЕ отметки, которую ставит отзыв отставшего. Отсечка, судящая по моменту выпуска, пропустила бы ровно этот токен — токен пары, ради снятия которой отзыв исполнялся. Предпосылка — проба TestConcurrentCodeReplayWinnerMayIssueAfterTheRevocation.

Причина отзыва

Каждый вызов несёт причину (RevocationReason) — всегда одну из словаря. Семейство отзывают оба метода одной операции, и причина у них одна и та же. Повторный отзыв уже отозванного семейства законен (ноль строк — не отказ); причину уже отозванного семейства служба вправе не переписывать: первая причина и есть та, по которой семейство умерло.

type IntrospectionRequest

type IntrospectionRequest struct {
	// Token — предъявленный артефакт.
	Token string

	// KindHint — подсказка о виде артефакта. TokenKindUnspecified
	// означает «подсказки нет», и это законное значение.
	KindHint TokenKind

	// Scopes — области, наличие которых требуется подтвердить.
	Scopes []string

	// ClientID / ClientSecret / AuthMethod — чем доказывает себя тот, кто
	// спрашивает. Интроспекция без доказательства запрещена RFC 7662 §2.1.
	//
	// AuthMethod принимает ОДИН способ — ClientAuthBasic
	// (IntrospectionAuthMethods): движок на этой точке берёт доказательство
	// только заголовком Authorization. Пустое значение разрешается так же, как
	// в TokenRequest: при непустом секрете — ClientAuthBasic, при пустом —
	// ClientAuthNone. ClientAuthPost и ClientAuthNone, названные явно или
	// полученные разрешением пустого, Introspect отвергает по имени до
	// обращения к движку (ErrCeremonyMisuse): иначе они уезжали бы в движок и
	// получали его отказ «заголовка Authorization нет», не называющий способа.
	ClientID     string
	ClientSecret string
	AuthMethod   ClientAuthMethod
}

IntrospectionRequest — запрос интроспекции (RFC 7662 §2.1).

type IntrospectionResult

type IntrospectionResult struct {
	Active bool

	// Kind — вид артефакта, если его удалось определить.
	Kind TokenKind

	// AccessTokenType — тип токена доступа ("bearer"). Пусто для токена
	// обновления.
	AccessTokenType string

	// GrantID — идентификатор гранта, породившего артефакт: тот, что выдал
	// крючок Config.NewGrantID.
	GrantID string

	ClientID  string
	Subject   string
	Username  string
	Scopes    []string
	Audiences []string

	// SessionID, ACR, AuthTime — контекст входа гранта: снимок сессии на
	// выдаче кода (SessionRecord).
	SessionID string
	ACR       string
	AuthTime  time.Time

	// IssuedAt — когда возник грант.
	IssuedAt time.Time

	// ExpiresAt — когда артефакт перестаёт быть годным. Нулевое время
	// означает «срок не назначен», а не «уже истёк».
	ExpiresAt time.Time

	// Claims — дополнительные утверждения сеанса.
	Claims map[string]any
}

IntrospectionResult — ответ интроспекции (RFC 7662 §2.2).

Active=false — ЗАКОННЫЙ ИСХОД, а не отказ: так отвечают и на выдуманный артефакт, и на погашенный. Прочие поля при Active=false пусты по требованию RFC — иначе интроспекция стала бы прибором для перебора.

type IssuedAccessToken

type IssuedAccessToken struct {
	// Token — значение токена, которое уезжает клиенту как есть.
	Token string

	// ID — идентификатор токена (утверждение `jti`). Под ним церемония кладёт
	// грант и под ним же ищет его при предъявлении.
	ID string

	// IssuedAt, ExpiresAt — момент выпуска и срок годности, ровно те, что
	// лежат в токене (`iat`, `exp`).
	IssuedAt  time.Time
	ExpiresAt time.Time
}

IssuedAccessToken — выпущенный токен доступа.

type Ports

type Ports struct {
	Clients            ClientDirectory
	AuthorizationCodes AuthorizationCodeVault
	AccessTokens       AccessTokenVault
	RefreshTokens      RefreshTokenVault
	Grants             GrantRevoker
	AccessTokenIssuer  AccessTokenIssuer
	ClientSecrets      ClientSecretVerifier

	// Transaction — необязателен. Пусто означает «хранилище службы не
	// умеет транзакций», и церемония ведёт себя соответственно.
	Transaction UnitOfWork
}

Ports — полный набор портов церемонии.

Все поля, кроме Transaction, ОБЯЗАТЕЛЬНЫ: New отвергает набор с пустым полем поимённо. Необязательное хранилище означало бы ветку «а если его нет», то есть второй, непроверяемый путь исполнения.

type PresentedSecret

type PresentedSecret struct {
	// contains filtered or unexported fields
}

PresentedSecret — секрет клиента В ЧИСТОМ ВИДЕ, как его предъявил клиент.

Почему отдельный тип, а не строка

Значение уезжает службе — в порт сверки (ClientSecretVerifier), то есть в код, который пишет журнал. Строку, переданную в журнал аргументом или полем структуры, форматирование напечатало бы целиком. Этот тип значения не печатает: String отдаёт метку, а само значение лежит за указателем, и печать поля-указателя даёт адрес при ЛЮБОМ глаголе — в том числе там, где String не зовётся (глагол %d, поле, закрытое от отражения). Значение отдаёт только Reveal — и каждое его чтение видно в коде по имени.

Нулевое значение законно: клиент секрета не предъявил.

func NewPresentedSecret

func NewPresentedSecret(value string) PresentedSecret

NewPresentedSecret — предъявленный секрет из его значения. Церемония собирает его сама; конструктор нужен службе, которая проверяет свою реализацию порта.

func (PresentedSecret) Reveal

func (s PresentedSecret) Reveal() string

Reveal отдаёт значение — для сверки постоянного времени, и только для неё. У нулевого значения — пустая строка: секрета не предъявили.

func (PresentedSecret) String

func (PresentedSecret) String() string

String отдаёт метку без значения.

type ProofKeyBinding

type ProofKeyBinding struct {
	// Challenge — `code_challenge`.
	Challenge string

	// Method — `code_challenge_method`.
	Method ProofKeyMethod
}

ProofKeyBinding — привязка кода авторизации к доказательству владения ключом (PKCE, RFC 7636): вызов, присланный клиентом в запросе авторизации, и метод, которым он получен.

Привязка ОБЯЗАТЕЛЬНА: запрос кода без годной привязки церемония отвергает до выдачи, и код без неё в хранилище не уезжает. Годна привязка ровно одна — метод ProofKeyMethodS256 и вызов в 43 знака алфавита base64url без выравнивания (свёртка SHA-256).

type ProofKeyMethod

type ProofKeyMethod string

ProofKeyMethod — метод, которым из доказательства владения ключом (`code_verifier`) получен вызов (`code_challenge`), RFC 7636 §4.2.

const (
	// ProofKeyMethodS256 — вызов есть BASE64URL(SHA256(code_verifier)).
	ProofKeyMethodS256 ProofKeyMethod = "S256"
)

Методы доказательства. Перечень ЗАКРЫТ и состоит из одного значения: `plain` сводит доказательство к передаче секрета в открытом виде по тому же каналу, что и код, и церемония его не принимает ни от клиента, ни из хранилища.

func ProofKeyMethods

func ProofKeyMethods() []ProofKeyMethod

ProofKeyMethods возвращает словарь методов целиком — службе, которая сопрягает его со своим (ограничение столбца метода) и обязана убедиться, что сопряжение полное.

func (ProofKeyMethod) Declared

func (m ProofKeyMethod) Declared() bool

Declared отвечает, входит ли метод в словарь. Нулевое значение НЕ входит: по RFC 7636 §4.3 неназванный метод означает `plain`.

type ProtocolError

type ProtocolError struct {
	// Code — случай. Единственное поле, по которому дозволено ветвиться.
	Code FailureCode

	// Description — описание случая для человека. Годится проводу.
	Description string

	// Hint — подсказка, что делать. Годится проводу.
	Hint string

	// Debug — внутренние подробности: текст отказа хранилища, имя порта,
	// имя обработчика. НЕ ГОДИТСЯ ПРОВОДУ никогда: здесь оседают строки
	// движка и строки подключения к базе. Годится журналу.
	Debug string
	// contains filtered or unexported fields
}

ProtocolError — НАША форма отказа. Единственный тип ошибки, который покидает этот пакет.

Что значит «различать по значению»

Вызывающий пишет `errors.Is(err, oauthceremony.ErrInvalidGrant)` и получает ответ, не читая ни одной строки текста. Сравнение идёт по полю Code через метод Is (см. ниже), поэтому совпадает и с образцом-часовым, у которого нет ни описания, ни подсказки, и с настоящим отказом, у которого они есть.

Обратное тоже обязано выполняться: два РАЗНЫХ случая никогда не совпадают, как бы ни совпали их тексты. Это держится пробой TestErrorsAreDistinctByValue.

Почему указатель

Часовой обязан быть сравним по указателю тоже (`err == ErrInvalidGrant` иногда пишут по привычке), и обязан быть неизменяемым для вызывающего. Значение-структура дало бы копию, которую вызывающий мог бы дополнить и передать дальше как наш часовой.

func (*ProtocolError) Error

func (e *ProtocolError) Error() string

Error печатает имя случая и описание. Текст НЕ является контрактом и не годится для ветвления — для этого есть Code.

func (*ProtocolError) HTTPStatus

func (e *ProtocolError) HTTPStatus() int

HTTPStatus — состояние ответа, приличное этому случаю.

func (*ProtocolError) Is

func (e *ProtocolError) Is(target error) bool

Is сравнивает по ЗНАЧЕНИЮ случая, а не по указателю и не по тексту.

func (*ProtocolError) Unwrap

func (e *ProtocolError) Unwrap() error

Unwrap отдаёт исходный отказ, чтобы errors.Is по часовым стандартной библиотеки (context.DeadlineExceeded, context.Canceled) продолжал работать сквозь нашу оболочку. Возвращаемый тип — интерфейс error, поэтому чужой ТИП наружу не проходит: вызывающий не может его назвать.

func (*ProtocolError) WireCode

func (e *ProtocolError) WireCode() string

WireCode — значение поля `error` ответа по RFC 6749 §5.2.

type RefreshTokenIssuance

type RefreshTokenIssuance uint8

RefreshTokenIssuance — когда выдавать токен обновления.

const (
	// RefreshTokenIssuanceUnspecified — правило не названо. Отвергается
	// в New: молчаливый выбор здесь решал бы за службу, живёт ли у неё
	// бессрочный доступ.
	RefreshTokenIssuanceUnspecified RefreshTokenIssuance = iota
	// RefreshTokenIssuanceOnScope — токен обновления выдаётся, только
	// если выдана одна из RefreshTokenScopes.
	RefreshTokenIssuanceOnScope
	// RefreshTokenIssuanceAlways — токен обновления выдаётся при каждом
	// обмене, где он применим.
	RefreshTokenIssuanceAlways
)

Правила выдачи токена обновления.

type RefreshTokenVault

type RefreshTokenVault interface {
	// StoreRefreshToken кладёт грант под подписью токена обновления —
	// sha256 его значения в hex, — связывая его с подписью (jti)
	// выпущенного вместе с ним токена доступа. Связь нужна, чтобы отзыв
	// одного снимал второй.
	StoreRefreshToken(ctx context.Context, signature, accessSignature string, grant GrantRecord) (StoreOutcome, error)

	// FetchRefreshToken отдаёт грант по подписи.
	//
	// Исходов ТРИ, и все три названы:
	//
	//  1. Токен есть и не обёрнут → (грант, nil).
	//  2. Токена нет вовсе либо его семейство отозвано (см. GrantRevoker) →
	//     (пустой грант, ErrGrantNotFound).
	//  3. Токен есть и УЖЕ ОБЁРНУТ → (ГРАНТ, ErrRefreshTokenRotated).
	//
	// Третий исход — ПОВТОР (RFC 9700 §4.14.2): токеном владеют двое, и
	// сервер не знает, кто из них законный. Грант обязан ехать ВМЕСТЕ с
	// ошибкой по той же причине, что у погашенного кода: по нему отзывается
	// семейство. Обёрнутый токен, отданный как живой, превращает повтор в
	// обычный оборот, и пара, выданная первым оборотом, переживает его; грант
	// без идентификатора при третьем исходе — ErrPortContract (отзывать
	// нечего).
	FetchRefreshToken(ctx context.Context, signature string) (GrantRecord, error)

	// DropRefreshToken снимает токен обновления по подписи. Ноль строк —
	// законный исход, как и у DropAccessToken.
	DropRefreshToken(ctx context.Context, signature string) (StoreOutcome, error)

	// RotateRefreshToken помечает токен обновления использованным при
	// обороте.
	//
	// ОДНОЙ ИНСТРУКЦИЕЙ, по тем же основаниям, что и погашение кода:
	//
	//	UPDATE refresh_tokens
	//	   SET rotated_at = now()
	//	 WHERE grant_id = $1 AND signature = $2 AND rotated_at IS NULL
	//
	// Исходы: 1 → оборот наш; 0 → токен уже обернули — одновременный ПОВТОР:
	// выборку прошли двое, и этот оборот опередили. Церемония отвечает
	// ErrRefreshTokenRotated и отзывает семейство гранта; иное →
	// ErrPortContract.
	RotateRefreshToken(ctx context.Context, grantID, signature string) (StoreOutcome, error)
}

RefreshTokenVault — хранилище токенов обновления.

Реализует СЛУЖБА.

type ResponseDelivery

type ResponseDelivery string

ResponseDelivery — способ доставки ответа точки авторизации; спецификация `OAuth 2.0 Multiple Response Type Encoding Practices`.

const (
	DeliveryDefault  ResponseDelivery = ""
	DeliveryQuery    ResponseDelivery = "query"
	DeliveryFragment ResponseDelivery = "fragment"
	DeliveryFormPost ResponseDelivery = "form_post"
)

Способы доставки. DeliveryDefault означает «выбирает движок по типу ответа» и отличается от «доставка не названа» тем, что назван ЯВНО.

type ResponseKind

type ResponseKind string

ResponseKind — тип ответа точки авторизации (RFC 6749 §3.1.1).

const (
	ResponseKindCode ResponseKind = "code"
)

Типы ответа, которые церемония обслуживает.

ResponseKindToken (неявный поток) СОЗНАТЕЛЬНО ОТСУТСТВУЕТ: OAuth 2.1 исключает его, и заводить его здесь значило бы завести поверхность, которую нечем защитить.

type RevocationReason

type RevocationReason string

RevocationReason — почему церемония отзывает семейство гранта.

Зачем причина в порте

Все пути отзыва снимают одно и то же семейство одними и теми же методами GrantRevoker. По одному факту вызова служба не различила бы повтор кода авторизации, повтор токена обновления и отзыв клиентом, а различать их ей обязательно: причина ложится в её журнал и в её закрытый словарь, и разные причины означают разные события — атаку на код, атаку на токен и выход клиента из сеанса.

Почему у типа нет значения «причина не названа»

Отзыв без причины до порта не доходит: мост, не знающий, почему отзывает, отказывает случаем CodeCeremonyMisuse, не позвав порта. Значение «не названа» служба была бы вынуждена либо записать — и её закрытый словарь перестал бы быть закрытым, — либо отвергнуть, то есть оставить семейство живым. Нулевое значение типа словарю не принадлежит (Declared отвечает «нет»), и церемония его порту не передаёт.

const (
	// RevocationCodeReplay — код авторизации предъявлен повторно (RFC 6749
	// §4.1.2): последовательно, после обмена, либо одновременно с другим
	// обменом того же кода.
	RevocationCodeReplay RevocationReason = "code-replay"

	// RevocationRefreshReplay — предъявлен уже обёрнутый токен обновления
	// (RFC 9700 §4.14.2): последовательно, после оборота, либо одновременно
	// с другим оборотом того же токена.
	RevocationRefreshReplay RevocationReason = "refresh-replay"

	// RevocationClientRevoke — клиент попросил отзыва через точку отзыва
	// (RFC 7009 §2.1), в том числе прежним, уже обёрнутым токеном
	// обновления: семейство снимается по просьбе клиента, а не потому, что
	// токеном владеют двое.
	RevocationClientRevoke RevocationReason = "client-revoke"
)

Причины отзыва. Перечень ЗАКРЫТ: значение вне его церемония порту не передаёт, и служба вправе отвергать его как нарушение контракта. Написание — контракт со службой: она сопрягает его со своим словарём по значению.

func RevocationReasons

func RevocationReasons() []RevocationReason

RevocationReasons возвращает словарь целиком — службе, которая сопрягает его со своим и обязана убедиться, что сопряжение полное.

func (RevocationReason) Declared

func (r RevocationReason) Declared() bool

Declared отвечает, входит ли причина в словарь. Нулевое значение НЕ входит: «причина не названа» — не причина.

type RevocationRequest

type RevocationRequest struct {
	// Token — отзываемый артефакт.
	Token string

	// KindHint — подсказка о виде.
	KindHint TokenKind

	ClientID     string
	ClientSecret string
	AuthMethod   ClientAuthMethod
}

RevocationRequest — запрос отзыва (RFC 7009 §2.1).

type ScopeMatching

type ScopeMatching uint8

ScopeMatching — правило сопоставления запрошенной области с разрешённой.

const (
	// ScopeMatchingUnspecified — правило не названо. Отвергается в New:
	// молчаливый выбор здесь означал бы выбор политики безопасности за
	// того, кто её не назвал.
	ScopeMatchingUnspecified ScopeMatching = iota
	// ScopeMatchingExact — совпадение строка-в-строку.
	ScopeMatchingExact
	// ScopeMatchingWildcard — совпадение с образцом `a.b.*`.
	ScopeMatchingWildcard
)

Правила сопоставления.

type SecretVerdict

type SecretVerdict uint8

SecretVerdict — исход сверки предъявленного секрета клиента.

Почему нулевое значение не объявлено

По той же причине, что у StoreOutcome: реализация, забывшая назвать исход, вернула бы ноль, и ноль читался бы как тот из двух вердиктов, который назначили нулём. Нулевое значение и значение вне словаря церемония отвергает как нарушение контракта порта (ErrPortContract), а не толкует.

const (
	// SecretVerdictUnspecified — вердикт не назван. Не вердикт.
	SecretVerdictUnspecified SecretVerdict = iota
	// SecretMatched — предъявленный секрет совпал с проверочным значением
	// клиента: нынешним либо прежним на время оборота.
	SecretMatched
	// SecretMismatched — не совпал: секрет неверен, не предъявлен, либо
	// клиента, которого назвали, у службы нет.
	SecretMismatched
)

Вердикты сверки. Перечень ЗАКРЫТ.

func (SecretVerdict) Declared

func (v SecretVerdict) Declared() bool

Declared отвечает, входит ли вердикт в словарь. Нулевое значение НЕ входит.

type SessionRecord

type SessionRecord struct {
	// Subject — устойчивый идентификатор того, от чьего имени выдан грант.
	Subject string

	// Username — человекочитаемое имя. Необязательно; уезжает в ответ
	// интроспекции.
	Username string

	// SessionID, ACR, AuthTime — контекст входа: сессия, из которой рождён
	// грант, её уровень аутентификации (`acr`) и момент аутентификации
	// (`auth_time`). Это СНИМОК, сделанный на выдаче кода
	// (AuthorizationGrant): обмен кода и каждый оборот токена обновления
	// переносят его без изменения, и у всего семейства гранта он один.
	//
	// Все три обязательны и в записи из хранилища: пустая сессия, уровень,
	// который ранжирование acrlevel не ставит выше анонима, и нулевое время —
	// нарушение контракта порта (ErrPortContract), а не «значения нет».
	SessionID string
	ACR       string
	AuthTime  time.Time

	// ExpiresAt — срок годности по каждому виду артефакта, записанный
	// церемонией в миг выпуска. Служба возвращает карту такой, какой её
	// получила: ключ, который церемония записала, пропасть не может — без
	// срока токена обновления движок считает токен бессрочным. Нулевое
	// время церемония не пишет, и ноль в записи из хранилища — нарушение
	// контракта порта (ErrPortContract), а не «ключа нет».
	ExpiresAt map[TokenKind]time.Time

	// NotAfter — граница годности СЕМЕЙСТВА гранта по видам артефактов:
	// ни один артефакт вида, выпущенный по этому гранту, — ни первый, ни
	// выпущенный оборотом, — не живёт дольше. Отсутствие ключа — границы
	// нет. Служба хранит границу вместе с сеансом и отдаёт её в каждой
	// выборке: без неё оборот назначил бы срок из настроек поверх и
	// продлил бы семейство за границу. Нулевое время границей не бывает
	// (выдача его отвергает), и ноль в записи из хранилища — нарушение
	// контракта порта (ErrPortContract), а не «ключа нет»: нулевая граница
	// сжала бы к себе срок токена обновления, а нулевой срок движок читает
	// как «без срока».
	NotAfter map[TokenKind]time.Time

	// Claims — дополнительные утверждения, которые служба кладёт в сеанс.
	// Переживают сериализацию через JSON, поэтому значения обязаны быть
	// представимы в JSON.
	//
	// Ключей контекста входа (`sid`, `acr`, `auth_time`) в карте нет: у
	// этих значений есть поля, и второе место у них разошлось бы с первым.
	// Такой ключ в записи из хранилища — нарушение контракта порта
	// (ErrPortContract).
	Claims map[string]any
}

SessionRecord — состояние сеанса, переживающее запрос.

type StoreOutcome

type StoreOutcome struct {
	// contains filtered or unexported fields
}

StoreOutcome — исход записи: сколько строк она затронула.

Зачем отдельный тип, а не int64

Контракт всех пишущих портов здесь — ОДНОИНСТРУКЦИОННАЯ запись с условием в самой инструкции:

UPDATE authorization_codes
   SET consumed_at = now()
 WHERE signature = $1 AND consumed_at IS NULL

и возврат `tag.RowsAffected()`. Противоположный способ — «прочитать, проверить в коде, потом записать» — между чтением и записью оставляет окно, в котором второй запрос успевает погасить тот же код. Никакая изолированность транзакции этого окна не закрывает, если чтение и запись разнесены по разным инструкциям без блокировки.

Если бы метод возвращал `(int64, error)`, реализация, забывшая передать число, вернула бы ноль — и ноль неотличим от честного «строк не затронуто». Обе ветви выглядели бы одинаково, и ошибка реализации стала бы ошибкой протокола на проводе.

StoreOutcome отличает «не названо» от «названо нулём». Нулевое значение типа НЕ ОБЪЯВЛЕНО, и церемония отвергает его как нарушение контракта порта (ErrPortContract), а не как «строк не затронуто». Собрать объявленный исход можно ТОЛЬКО через RowsTouched, то есть только назвав число.

Что это НЕ гарантирует

Тип не может заставить реализацию исполнить одну инструкцию вместо двух. Он делает другое: делает невозможным ВЕРНУТЬ исход, не назвав число, и делает видимым число, отличное от единицы. Реализация «проверить-потом-записать», честно вернувшая RowsTouched(1) после гонки, всё равно даст двойное погашение — и это ловится пробой службы на конкурентное погашение, не типом. Тип закрывает МОЛЧАЛИВЫЙ путь, а не все пути.

func RowsTouched

func RowsTouched(n int64) StoreOutcome

RowsTouched собирает исход записи из числа, которое вернул драйвер.

tag, err := tx.Exec(ctx, q, sig)
if err != nil { return oauthceremony.StoreOutcome{}, err }
return oauthceremony.RowsTouched(tag.RowsAffected()), nil

Отрицательное число — тоже законный вход: драйвер, который не умеет считать строки, возвращает −1, и церемония обязана увидеть это как нарушение контракта, а не превратить в ноль по дороге.

func (StoreOutcome) Declared

func (o StoreOutcome) Declared() bool

Declared — назван ли исход. Ложь означает нулевое значение типа, то есть реализацию, вернувшую исход в обход RowsTouched.

func (StoreOutcome) Rows

func (o StoreOutcome) Rows() int64

Rows — число затронутых строк. Осмысленно только при Declared() == true.

type TokenKind

type TokenKind string

TokenKind — вид артефакта, выпускаемого церемонией.

const (
	TokenKindAccess            TokenKind = "access_token"
	TokenKindRefresh           TokenKind = "refresh_token"
	TokenKindAuthorizationCode TokenKind = "authorization_code"
	// TokenKindUnspecified — вид не назван. Отдельно от пустой строки в
	// значении поля: в интроспекции «вид определить не удалось» —
	// законный исход, и он обязан быть выразим.
	TokenKindUnspecified TokenKind = ""
)

Виды артефактов.

Токена личности (`id_token`, OpenID Connect) здесь НЕТ: обработчика OpenID Connect церемония не провязывает (doc.go), и вид без выпуска обещал бы поведение, которого нет. Выдача токена личности — свой предмет со своим портом ключей и своими настройками.

func TokenKinds

func TokenKinds() []TokenKind

TokenKinds возвращает словарь видов, которые церемония ВЫПУСКАЕТ, — службе, которая хранит сроки и границы по видам (SessionRecord.ExpiresAt, SessionRecord.NotAfter) и сопрягает словарь со своим (ограничение столбца вида). Вида без выпуска в словаре нет: граница или срок под ним не значили бы ничего.

func (TokenKind) Declared

func (k TokenKind) Declared() bool

Declared отвечает, входит ли вид в словарь выпускаемых. Нулевое значение (TokenKindUnspecified) НЕ входит: это «вид не назван», а не вид.

type TokenRequest

type TokenRequest struct {
	// Grant — `grant_type`. Обязательно, и обязано входить в словарь.
	Grant GrantKind

	// ClientID — `client_id`.
	ClientID string

	// ClientSecret — секрет клиента В ЧИСТОМ ВИДЕ, как его прислал
	// клиент. Живёт ровно до конца вызова и уезжает ровно в одно место — в
	// порт сверки службы (ClientSecretVerifier), обёрнутым в PresentedSecret:
	// ни в GrantRecord, ни в ProtocolError, ни в журнале он не оседает.
	ClientSecret string

	// AuthMethod — каким способом доказывать клиента. Пустое значение
	// означает ClientAuthNone при пустом секрете и ClientAuthBasic при
	// непустом; иного умолчания здесь быть не может, и оно названо, а не
	// подразумевается.
	AuthMethod ClientAuthMethod

	// Code — `code`. Только для GrantAuthorizationCode.
	Code string

	// RedirectURI — `redirect_uri`. Обязателен, если был в запросе
	// авторизации (RFC 6749 §4.1.3).
	RedirectURI string

	// CodeVerifier — `code_verifier` (PKCE, RFC 7636).
	CodeVerifier string

	// RefreshToken — `refresh_token`. Только для GrantRefreshToken.
	RefreshToken string

	// Scopes — `scope`. Сужение относительно гранта.
	Scopes []string

	// Audiences — `audience`.
	Audiences []string

	// Additional — прочие протокольные поля: поля расширений. Утверждение
	// клиента (`client_assertion`) сюда класть бесполезно — движок его
	// отвергает (см. ClientAuthMethod).
	Additional map[string][]string
}

TokenRequest — запрос к точке токена.

type TokenResult

type TokenResult struct {
	// AccessToken — токен доступа.
	AccessToken string

	// TokenType — тип токена доступа; для церемонии всегда "bearer".
	TokenType string

	// ExpiresIn — срок жизни выпущенного токена доступа: его exp минус
	// момент выпуска, ровно те, что порт выпуска положил в токен
	// (AccessTokenIssuer, RFC 6749 §5.1 `expires_in`).
	ExpiresIn time.Duration

	// RefreshToken — токен обновления. Пусто, если не выдавался.
	RefreshToken string

	// Scopes — выданные области.
	Scopes []string

	// Additional — прочие поля ответа, положенные обработчиками
	// расширений. Именованные поля выше сюда НЕ дублируются.
	Additional map[string]any
}

TokenResult — ответ точки токена (RFC 6749 §5.1).

type UnitOfWork

type UnitOfWork interface {
	// Begin открывает единицу работы и возвращает контекст, несущий её.
	// Все последующие вызовы портов придут с этим контекстом.
	Begin(ctx context.Context) (context.Context, error)

	// Commit закрепляет единицу работы, найденную в контексте.
	Commit(ctx context.Context) error

	// Rollback отменяет единицу работы, найденную в контексте.
	Rollback(ctx context.Context) error
}

UnitOfWork — НЕОБЯЗАТЕЛЬНЫЙ порт единицы работы.

Реализует СЛУЖБА, если её хранилище умеет транзакции.

Что он даёт и чего НЕ даёт

Движок оборачивает транзакцией выдачу — «положить токен доступа + положить токен обновления» (на обороте токена обновления — вместе с его оборотом): либо уедет всё, либо ничего. Погашение кода в эту связку НЕ входит: церемония гасит код раньше, при предъявлении (см. AuthorizationCodeVault), и код, чья выдача откатилась, остаётся погашенным — предъявленный код второго предъявления не получает. Транзакция не защищает и от повторного использования кода — чтение кода стоит до её открытия. Один порт закрывает частичную запись, другой — гонку; подменять один другим нельзя.

Если порт не назван, церемония собирает мост БЕЗ транзакционных методов, и движок, проверяющий их наличие утверждением типа, честно видит хранилище без транзакций. Мост с методами-пустышками солгал бы движку, и связка выше перестала бы быть неделимой молча.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL