oidckit

package
v0.57.0 Latest Latest
Warning

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

Go to latest
Published: Aug 12, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

README

oidckit

OIDC BFF-контур для входа через внешнего OIDC-провайдера: сервис сам ведёт Authorization-Code-flow (+ PKCE S256), проверяет ID-token по JWKS и выдаёт собственную cookie-сессию — браузер OIDC-токенов не видит.

Провайдер задаётся только конфигом (OIDC_ISSUER + client id/secret): kit работает с любым IdP, отдающим discovery-документ и асимметрично подписанный ID-token. Имени вендора в коде нет.

Внутри два слоя одного пакета:

  • Relying-partyVerifier: discovery /.well-known/openid-configuration, JWKS-кэш с lazy-refresh по неизвестному kid, verify iss/aud/exp/nonce (только асимметричные подписи: RS256/384/512, ES256/384), обмен authorization code; PKCE() / RandomToken(). Крипта — go-jose/v4.
  • BFF-обвязкаBFF + huma-роуты GET <p>/login (302 на idp), GET <p>/callback (exchange+verify → cookie-сессия), GET <p>/me, POST <p>/logout; Extractor (cookie → authnkit.JWTParserIdentity в ctx), совместимый с httpkit.AuthExtractor.

Сессия — HS256-JWT (authnkit.HS256JWT, секрет JWT_SECRET) в httpOnly-cookie access_token (SameSite=Strict, Secure по COOKIE_SECURE — default true), TTL — OIDC_SESSION_TTL (default 12h). Короткоживущий state логин-флоу (PKCE verifier / nonce / return-to; TTL 10m, one-time) живёт в StateStore: NewCacheStateStore (Redis через cachekit) или NewMemoryStateStore (тесты / single-replica без Redis).

Kit не трактует роли и не знает продуктовых пользователей: маппинг claims → роль/доп-claims сессии и JIT-провижининг — хуки консьюмера (Hooks).

Импорт

import "git.stit.tech/stit-core/golibinfra/oidckit"

Версия модуля — см. ../README.md (§ Версионирование).

Быстрый старт

fx-проводка. Module — drop-in BFF; caller провайдит Config/CookieConfig (env), StateStore и authnkit-подпись:

fx.New(
    fx.Provide(
        authnkit.NewJWTConfig,               // JWT_SECRET
        oidckit.NewConfig,                   // OIDC_* + OIDC_SESSION_TTL
        oidckit.NewCookieConfig,             // COOKIE_SECURE
        fx.Annotate(oidckit.NewCacheStateStore, fx.As(new(oidckit.StateStore))), // поверх cachekit.Cache
        func() oidckit.Hooks {               // опционально
            return oidckit.Hooks{
                SessionClaims: func(c oidckit.Claims) map[string]any {
                    return map[string]any{"role": roleFor(c.Username)}
                },
                OnLogin: func(ctx context.Context, c oidckit.Claims) error {
                    return employees.SyncOnLogin(ctx, c) // JIT-провижининг
                },
            }
        },
    ),
    authnkit.Module,
    cachekit.Module,
    httpkit.Module,
    oidckit.Module, // роуты /auth/*, security-scheme Cookie, AuthExtractor, user_id в логах
)

Защищаемая операция ссылается на схему:

huma.Operation{ ..., Security: []map[string][]string{{oidckit.SecuritySchemeName: {}}} }

Хендлер читает principal из ctx:

id, ok := oidckit.IdentityFrom(ctx) // Identity{Subject, Username, Name, Email, Role}
Составной authn (несколько схем)

httpkit принимает один AuthExtractor. Сервису, где cookie-сессия — лишь одна из схем (например плюс bearer сервис-токены), Module не подходит: собери проводку из конструкторов (NewBFF, NewExtractor, NewSecurityScheme, RegisterRoutes, RegisterIdentityLogAttr) и предоставь свой композитный httpkit.AuthExtractor, который делегирует (*Extractor).Extract для cookie-ветки.

Конфигурация

Env Поле Default Назначение
OIDC_ISSUER Config.Issuer issuer idp; пусто → login/callback отвечают 503
OIDC_CLIENT_ID Config.ClientID OIDC client id
OIDC_CLIENT_SECRET Config.ClientSecret client secret (token endpoint)
OIDC_REDIRECT_URL Config.RedirectURL callback-URL сервиса (redirect_uri)
OIDC_POST_LOGIN_URL Config.PostLoginURL / redirect после входа (если нет return_to)
OIDC_SESSION_TTL Config.SessionTTL 12h срок жизни cookie-сессии
Config.RoutePrefix /auth префикс BFF-роутов (программный, не env)
COOKIE_SECURE CookieConfig.Secure true флаг Secure сессионной cookie; false на боевой среде — отказ на старте (см. ниже)
COOKIE_DOMAIN CookieConfig.Domain "" (host-only) Domain сессионной cookie; нужен, когда сессию выдаёт один хост, а читает другой
APP_ENV CookieConfig.Env dev среда; определяет, считать ли COOKIE_SECURE=false дефектом конфигурации
Сессия по plain HTTP — отказ, а не предупреждение

NewCookieConfig()CookieConfig.Validate() для сервиса, собирающего конфиг сам) отказывает, если APP_ENV боевой, а COOKIE_SECURE=false: cookie без флага Secure уезжает по plain HTTP, и в рантайме это не отличить от нормальной работы. Не-боевые среды (dev/local/test/ci/e2e/smoke) проверку не проходят — они её не запускают; неизвестное значение APP_ENV считается боевым.

Дефолт Secure=true (как у hubkit) локальную разработку не ломает: http://localhost браузеры считают доверенным origin'ом и Secure-cookie на нём принимают. Выключать приходится только там, где HTTP идёт не через localhost — и тогда это явное решение, записанное в env этого контура.

Секрет подписи сессии — JWT_SECRET (authnkit.JWTConfig, kit получает готовые JWTSigner/JWTParser).

API

  • NewVerifier(ctx, RPConfig, *http.Client) (*Verifier, error); (*Verifier).AuthCodeURL / Exchange / VerifyClaims{Subject, Username, Name, Email}.
  • PKCE() (verifier, challenge, error), RandomToken() (string, error).
  • StateStore (Set/GetDel, one-time), NewCacheStateStore(cachekit.Cache), NewMemoryStateStore().
  • NewBFF(Config, authnkit.JWTSigner, StateStore, CookieConfig, Hooks, logkit.Logger) *BFF, RegisterRoutes(api, *BFF).
  • Hooks{SessionClaims, OnLogin} — доп-claims сессии / JIT-провижининг (ошибка OnLogin отменяет вход).
  • NewExtractor(authnkit.JWTParser) *Extractor; Extract совместим с httpkit.AuthExtractor; Identity + WithIdentity/IdentityFrom/MustIdentityFrom.
  • SecuritySchemeName ("Cookie"), NewSecurityScheme(), SessionCookie, ExpiredSessionCookies, RegisterIdentityLogAttr.

Observability

Собственных метрик kit не эмитит: HTTP-сигналы дают middleware httpkit (RED + access-log), verify/parse сессии учитывается счётчиками authnkit (authn_jwt_verify_total), запросы к idp — спанами обвязки. Extractor пробрасывает user_id в каждый log-record (RegisterIdentityLogAttr, Module регистрирует сам).

Инварианты

  • ID-token принимается только с асимметричной подписью (RS*/ES*) — HS-подпись idp не принимается даже валидная.
  • state одноразовый: повторный callback с тем же state → 400.
  • return_to — только внутренний absolute-path; внешние и protocol-relative URL молча заменяются на OIDC_POST_LOGIN_URL (anti open-redirect).
  • Discovery ленивый (первый login/callback), недоступность idp не мешает старту приложения и не задевает живые cookie-сессии (verify по JWT_SECRET локально).

Documentation

Overview

Package oidckit — OIDC BFF-контур для входа через внешнего OIDC-провайдера: relying-party (discovery, JWKS-кэш, verify ID-token, Authorization Code + PKCE), huma-роуты login/callback/me/logout, cookie-сессия HS256 (authnkit) и extractor для httpkit.HumaAuth. State логин-флоу — через StateStore (Redis/in-memory).

Index

Constants

View Source
const (
	// CookieAccess — httpOnly-cookie c session-JWT (единственная сессионная кука BFF).
	CookieAccess = "access_token"
	// CookieRefresh чистится на logout заодно с access: у сервисов с отдельным
	// refresh-контуром не должно оставаться живой пары после выхода.
	CookieRefresh = "refresh_token"
)
View Source
const SecuritySchemeName = "Cookie"

SecuritySchemeName — имя openapi security-scheme cookie-сессии; операции защищаются `Security: []map[string][]string{{oidckit.SecuritySchemeName: {}}}`.

Variables

View Source
var (
	ErrRPConfig       = errors.New("oidckit: issuer and client id required")
	ErrIssuerMismatch = errors.New("oidckit: discovery issuer mismatch")
	ErrNoJWKS         = errors.New("oidckit: discovery has no jwks_uri")
	ErrNoEndpoints    = errors.New("oidckit: discovery has no authorization/token endpoint")
	ErrInvalidToken   = errors.New("oidckit: malformed id token")
	ErrUnknownKey     = errors.New("oidckit: no signing key for kid")
	ErrNoSubject      = errors.New("oidckit: id token has no subject")
	ErrNonceMismatch  = errors.New("oidckit: id token nonce mismatch")
	ErrNoIDToken      = errors.New("oidckit: token response has no id_token")
	ErrExchange       = errors.New("oidckit: code exchange failed")
)
View Source
var ErrNoSessionCookie = errors.New("oidckit: no auth cookie")
View Source
var Module = fx.Module(
	"oidckit",
	fx.Provide(
		newBFF,
		NewExtractor,

		func(e *Extractor) httpkit.AuthExtractor { return e.Extract },
		fx.Annotate(NewSecurityScheme, fx.ResultTags(`group:"httpkit.security_scheme"`)),
	),
	fx.Invoke(RegisterIdentityLogAttr),
	fx.Invoke(RegisterRoutes),
)

Module — drop-in BFF: роуты login/callback/me/logout, security-scheme Cookie, httpkit.AuthExtractor (cookie → Identity в ctx), user_id в логах.

Module не provide'ит Config/CookieConfig (asymmetry note в pgkit/module.go — caller добавляет `fx.Provide(oidckit.NewConfig, oidckit.NewCookieConfig)`), StateStore (Redis: `fx.Provide(fx.Annotate(oidckit.NewCacheStateStore, ...))` поверх cachekit; либо NewMemoryStateStore) и JWTSigner/JWTParser (authnkit.Module). Hooks опциональны. Сервису с составным extractor'ом (несколько схем authn) Module не подходит — собирай из конструкторов, httpkit принимает один AuthExtractor.

Functions

func ExpiredSessionCookies

func ExpiredSessionCookies(cfg CookieConfig) []http.Cookie

ExpiredSessionCookies — Set-Cookie на сброс сессии (logout): access + refresh.

func NewSecurityScheme

func NewSecurityScheme() httpkit.SecuritySchemeEntry

NewSecurityScheme — запись для `components.securitySchemes` (apiKey in cookie). Module кладёт её в group `httpkit.security_scheme` сам; без fx — передай в httpkit.NewServer.

func PKCE

func PKCE() (verifier, challenge string, err error)

PKCE генерирует пару (verifier, challenge) для Authorization Code + PKCE (S256).

func RandomToken

func RandomToken() (string, error)

RandomToken — криптослучайный URL-safe токен (state, nonce, PKCE-verifier).

func RegisterIdentityLogAttr

func RegisterIdentityLogAttr()

RegisterIdentityLogAttr пробрасывает subject сессии в каждый log-record как user_id (logkit user-registered attr). Дергается один раз на старте (Module делает это сам).

func RegisterRoutes

func RegisterRoutes(api huma.API, b *BFF)

RegisterRoutes регистрирует BFF-роуты под Config.RoutePrefix (default /auth): GET <p>/login, GET <p>/callback, GET <p>/me (Security: Cookie), POST <p>/logout.

func SessionCookie

func SessionCookie(value string, cfg CookieConfig) http.Cookie

SessionCookie — httpOnly session-cookie (SameSite=Strict; Secure по COOKIE_SECURE).

func WithIdentity

func WithIdentity(ctx context.Context, id Identity) context.Context

Types

type BFF

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

BFF ведёт Authorization-Code-flow: login → idp → callback, после чего минтит session-cookie (HS256/JWT_SECRET), которую читает Extractor; плюс me/logout.

func NewBFF

func NewBFF(cfg Config, signer authnkit.JWTSigner, store StateStore, cookie CookieConfig, hooks Hooks, log logkit.Logger) *BFF

type Claims

type Claims struct {
	Subject  string
	Username string // preferred_username — логин (напр. n.sycheva)
	Name     string // name — ФИО (напр. Наталья Сычева)
	Email    string
}

Claims — то, что вытащили из проверенного ID-token'а.

type Config

type Config struct {
	Issuer       string `envconfig:"OIDC_ISSUER"`
	ClientID     string `envconfig:"OIDC_CLIENT_ID"`
	ClientSecret string `envconfig:"OIDC_CLIENT_SECRET"`
	RedirectURL  string `envconfig:"OIDC_REDIRECT_URL"`
	PostLoginURL string `envconfig:"OIDC_POST_LOGIN_URL" default:"/"`
	// SessionTTL — срок жизни cookie-сессии (exp session-JWT).
	SessionTTL time.Duration `envconfig:"OIDC_SESSION_TTL" default:"12h"`
	// RoutePrefix — префикс BFF-роутов (login/callback/me/logout); программный,
	// не env: пути — часть API-контракта сервиса, пусто → "/auth".
	RoutePrefix string `ignored:"true"`
}

Config — env-конфиг BFF-контура (имена OIDC_* / COOKIE_SECURE — public API kit'а).

func NewConfig

func NewConfig() (Config, error)

type CookieConfig

type CookieConfig struct {
	// Secure — флаг Secure сессионной cookie. Дефолт true, как у hubkit
	// (`HUB_SESSION_COOKIE_SECURE`): cookie без него ходит по plain HTTP, а
	// http://localhost браузеры считают доверенным origin'ом и Secure-cookie на
	// нём принимают — то есть строгий дефолт локальную разработку не ломает.
	// Выключать приходится только на контуре, где HTTP идёт не через localhost.
	Secure bool `envconfig:"COOKIE_SECURE" default:"true"`
	// Domain пусто — host-only (строже: cookie не поедет на соседние поддомены).
	// `.<домен>` нужен, когда сессию выдаёт один хост, а читает другой.
	Domain string `envconfig:"COOKIE_DOMAIN"`
	// Env — та же APP_ENV, что в logkit/telemetry. Читается здесь, чтобы
	// комбинация «боевая среда + Secure=false» отказывала на старте, а не
	// работала молча (см. Validate).
	Env string `envconfig:"APP_ENV" default:"dev"`
}

func NewCookieConfig

func NewCookieConfig() (CookieConfig, error)

func (CookieConfig) Validate added in v0.57.0

func (c CookieConfig) Validate() error

Validate отказывает на боевой среде без Secure. Отдельный метод, а не только проверка в NewCookieConfig: сервис, собирающий CookieConfig из своего App-конфига, обязан иметь тот же гейт под рукой.

type Extractor

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

Extractor восстанавливает Identity защищённого запроса: cookie access_token → authnkit.JWTParser (тот же JWT_SECRET, что подписал BFF) → Identity в ctx. Сигнатура Extract совместима с httpkit.AuthExtractor.

func NewExtractor

func NewExtractor(parser authnkit.JWTParser) *Extractor

func (*Extractor) Extract

func (e *Extractor) Extract(r *http.Request) (context.Context, error)

type Hooks

type Hooks struct {
	// SessionClaims маппит проверенные claims ID-token'а в дополнительные claims
	// session-JWT (например "role" по списку админов). Ключи sub /
	// preferred_username / name / email заполняет сам kit; hook может их переопределить.
	SessionClaims func(c Claims) map[string]any
	// OnLogin выполняется после успешного exchange+verify, до выдачи session-cookie
	// (JIT-провижининг пользователя у консьюмера). Ошибка отменяет вход.
	OnLogin func(ctx context.Context, c Claims) error
}

Hooks — точки расширения BFF; оба поля опциональны.

type Identity

type Identity struct {
	Subject  string
	Username string // preferred_username — логин (напр. n.sycheva)
	Name     string // name — ФИО (напр. Наталья Сычева)
	Email    string
	Role     string
}

Identity — principal cookie-сессии, восстановленный Extractor'ом из session-JWT. Role приезжает из claim'а "role" (его кладёт Hooks.SessionClaims консьюмера); семантика значений — на стороне сервиса, kit ролей не трактует.

func IdentityFrom

func IdentityFrom(ctx context.Context) (Identity, bool)

func MustIdentityFrom

func MustIdentityFrom(ctx context.Context) Identity

type MeView

type MeView struct {
	Subject  string `json:"sub"`
	Username string `json:"username,omitempty"`
	Name     string `json:"name,omitempty"`
	Email    string `json:"email,omitempty"`
	Role     string `json:"role,omitempty"`
}

MeView — публичная проекция сессионной identity для фронта.

type RPConfig

type RPConfig struct {
	Issuer       string
	ClientID     string
	ClientSecret string
	RedirectURL  string
}

RPConfig — параметры relying-party для Verifier (программные; env-контур BFF читает Config и строит RPConfig сам).

type StateStore

type StateStore interface {
	Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
	GetDel(ctx context.Context, key string) ([]byte, bool, error)
}

StateStore хранит короткоживущий state логин-флоу (PKCE verifier / nonce / return-to) между /auth/login и /auth/callback. Записи one-time: GetDel отдаёт значение и сразу удаляет его (повторный callback с тем же state отвергается).

func NewCacheStateStore

func NewCacheStateStore(c cachekit.Cache) StateStore

NewCacheStateStore — StateStore поверх cachekit.Cache (Redis): переживает рестарт приложения и работает при нескольких репликах.

func NewMemoryStateStore

func NewMemoryStateStore() StateStore

NewMemoryStateStore — StateStore в памяти процесса: для тестов и single-replica-развёртываний без Redis (state не переживает рестарт).

type Verifier

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

Verifier — OIDC relying-party: discovery /.well-known, JWKS-кэш с lazy-refresh по неизвестному kid, verify iss/aud/exp/nonce, обмен authorization code.

func NewVerifier

func NewVerifier(ctx context.Context, cfg RPConfig, client *http.Client) (*Verifier, error)

func (*Verifier) AuthCodeURL

func (v *Verifier) AuthCodeURL(state, nonce, challenge string) string

AuthCodeURL строит redirect на authorization_endpoint (Authorization Code + PKCE S256).

func (*Verifier) Exchange

func (v *Verifier) Exchange(ctx context.Context, code, codeVerifier, expectedNonce string) (Claims, error)

Exchange меняет authorization code на ID-token (token_endpoint), проверяет его и nonce.

func (*Verifier) Verify

func (v *Verifier) Verify(ctx context.Context, rawIDToken string) (Claims, error)

Verify проверяет ID-token без сверки nonce (для сценариев без code-flow).

Jump to

Keyboard shortcuts

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