Опирается на правила: AUTH-4, AUTH-5, AUTH-6 из Auth Patterns → раздел 2. JWT validation.

Важно знать

  • JWT валидируется через github.com/golang-jwt/jwt/v5 + github.com/MicahParks/keyfunc/v3. Самописный парсинг подписи запрещён.
  • JWK Set тянется из IdP по jwks_uri; keyfunc кеширует ключи и обновляет их автоматически — вручную распаковывать запрещено.
  • Невалидная подпись или просроченный exp401, не 403. Семантика критична для клиента.
  • 401 — «не аутентифицирован», клиент запускает refresh-flow или relogin.
  • 403 — «нет прав», клиент показывает «доступ запрещён», refresh не поможет.
  • AuthN-middleware кладёт *Principal в context.Context; handler читает через PrincipalFrom(ctx).
  • Raw-токен в context.Context хранить запрещено — только разобранный *Principal.
  • jwt.Parse без WithExpirationRequired() не проверяет exp — опция обязательна.

JWT validation — это точка, где сервис решает «пришёл реальный субъект или подделка». Один пропущенный claim — и attacker подписывает токен своим ключом, объявляет себя admin, делает всё что угодно. Поэтому AUTH-4 запрещает самописный парсинг и предписывает использовать library с production-tested реализацией.

AuthN-middleware

Валидация живёт в adapters/in/http/middleware/authn.go. Middleware стоит первым в цепочке chi — до любых handler'ов и RequireRoles.

// adapters/in/http/middleware/authn.go
package middleware

import (
    "net/http"
    "strings"

    "github.com/example/orders/adapters/in/http/httperr"
    "github.com/example/orders/adapters/in/http/security"
    "github.com/example/orders/core/apperr"
)

type ctxKey string

const principalKey ctxKey = "principal"

func AuthN(v *security.JWTValidator) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            header := r.Header.Get("Authorization")
            if !strings.HasPrefix(header, "Bearer ") {
                httperr.Write(w, r, &apperr.AuthError{Reason: "missing bearer token"})
                return
            }
            principal, err := v.Validate(strings.TrimPrefix(header, "Bearer "))
            if err != nil {
                httperr.Write(w, r, err)   // AUTH-6: AuthError → 401
                return
            }
            ctx := context.WithValue(r.Context(), principalKey, principal)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

func PrincipalFrom(ctx context.Context) *security.Principal {
    p, _ := ctx.Value(principalKey).(*security.Principal)
    return p
}

Роутер подключает middleware один раз на весь сервис:

// adapters/in/http/router.go
r := chi.NewRouter()
r.Use(middleware.AuthN(jwtValidator))   // AUTH-1: аутентификация на edge

r.Group(func(r chi.Router) {
    r.Use(middleware.RequireRoles("customer"))
    r.Post("/orders", h.CreateOrder)
    r.Get("/orders/{id}", h.GetOrder)
})

r.Group(func(r chi.Router) {
    r.Use(middleware.RequireRoles("admin"))
    r.Post("/orders/{id}/cancel", h.AdminCancelOrder)
})

JWTValidator и JWKS-кеш

AUTH-4: парсинг через golang-jwt/jwt/v5. AUTH-5: ключи из IdP через keyfunc/v3 — библиотека сама кеширует JWK Set и обновляет при rotation.

// adapters/in/http/security/jwt.go
package security

import (
    "fmt"

    "github.com/MicahParks/keyfunc/v3"
    "github.com/golang-jwt/jwt/v5"
)

type Principal struct {
    Sub   string
    Roles []string
}

type JWTValidator struct {
    jwks     *keyfunc.JWKS
    issuer   string
    audience string
}

func NewJWTValidator(jwksURI, issuer, audience string) (*JWTValidator, error) {
    jwks, err := keyfunc.NewDefault([]string{jwksURI})   // AUTH-5: кеш из IdP
    if err != nil {
        return nil, fmt.Errorf("jwks init: %w", err)
    }
    return &JWTValidator{jwks: jwks, issuer: issuer, audience: audience}, nil
}

func (v *JWTValidator) Validate(tokenStr string) (*Principal, error) {
    token, err := jwt.Parse(tokenStr, v.jwks.Keyfunc,
        jwt.WithIssuer(v.issuer),                              // AUTH-4: iss
        jwt.WithAudience(v.audience),                          // AUTH-4: aud
        jwt.WithValidMethods([]string{"RS256", "ES256"}),      // AUTH-4: alg whitelist
        jwt.WithExpirationRequired(),                          // AUTH-4: exp обязателен
    )
    if err != nil || !token.Valid {
        return nil, &apperr.AuthError{Reason: "invalid token"}  // AUTH-6 → 401
    }
    claims, ok := token.Claims.(jwt.MapClaims)
    if !ok {
        return nil, &apperr.AuthError{Reason: "malformed claims"}
    }
    return &Principal{
        Sub:   claims["sub"].(string),
        Roles: extractRoles(claims),
    }, nil
}

keyfunc.NewDefault при получении токена с неизвестным kid автоматически перезапрашивает JWK Set у IdP — rotation ключей покрыт без дополнительного кода.

Что проверяется jwt.Parse с опциями выше:

  • Подпись через public key из JWK Set.
  • exp — токен не просрочен (с учётом clock skew).
  • iss — совпадает с конфигом.
  • aud — токен адресован этому сервису.
  • alg — только RS256 или ES256; alg: none отклоняется.

Вручную хранить публичные ключи в конфиге или файловой системе запрещено — keyfunc делает это сам.

Конфигурация через envconfig

AUTH-17: jwks_uri, issuer, audience — из env, не в git.

// cmd/app/config.go
type Config struct {
    JWKSUri  string `envconfig:"JWKS_URI,required"`
    Issuer   string `envconfig:"JWT_ISSUER,required"`
    Audience string `envconfig:"JWT_AUDIENCE,required"`
}

// cmd/app/wire.go
jwtValidator, err := security.NewJWTValidator(cfg.JWKSUri, cfg.Issuer, cfg.Audience)
if err != nil {
    slog.Error("jwks init failed", "err", err)
    os.Exit(1)
}

401 vs 403

AUTH-6: коды ответа критичны — это контракт для клиента.

КодКогдаЧто делает клиент
401 UnauthorizedНевалидный JWT: плохая подпись, просроченный exp, отсутствующий заголовокRefresh-token flow или relogin
403 ForbiddenJWT валиден, но роль не подходит: RequireRoles отказал, ABAC отказалПоказать «доступ запрещён», не пытаться refresh

apperr.AuthError маппируется в 401; apperr.ForbiddenError — в 403. Маппинг в edge-renderer:

// adapters/in/http/httperr/render.go
func Write(w http.ResponseWriter, r *http.Request, err error) {
    var authErr *apperr.AuthError
    if errors.As(err, &authErr) {
        w.WriteHeader(http.StatusUnauthorized)   // 401
        json.NewEncoder(w).Encode(problem{Status: 401, Detail: "unauthenticated"})
        return
    }
    var forbErr *apperr.ForbiddenError
    if errors.As(err, &forbErr) {
        w.WriteHeader(http.StatusForbidden)       // 403
        json.NewEncoder(w).Encode(problem{Status: 403, Detail: "forbidden"})
        return
    }
    // ...
}

Сценарий поломки при путанице: клиент от OrderService получает 403 на просроченный токен. Клиент показывает «доступ запрещён» и не запускает refresh. Пользователь видит ошибку и не может войти, пока вручную не перелогинится. Корректный 401 запустил бы автоматический refresh.

Самописный парсинг — почему запрещён

Что ломается при ручной реализации:

  • Забыли проверить iss — attacker подсунул токен от своего IdP, сервис принял.
  • Забыли aud — токен от ProductService принят в OrderService.
  • alg: none в header — JWT без подписи принят как валидный.
  • Clock skew при проверке exp — просроченный токен проходит на 1-2 минуты вперёд.
  • JWK не cached — каждый запрос дёргает IdP, IdP становится single point of failure.
  • При rotation ключа — invalidation не работает, 401 на все валидные токены.

golang-jwt/jwt/v5 с keyfunc/v3 закрывает все эти случаи в production-tested коде. Custom-парсинг — это переизобретение этого кода с неизбежными пропусками.

Что запрещено

АнтипаттернПравилоЧто взамен
Самописный парсинг подписи JWTAUTH-4jwt.Parse + v.jwks.Keyfunc
jwt.Parse без WithExpirationRequired()AUTH-4опция обязательна
alg: none не отклоняетсяAUTH-4WithValidMethods([]string{"RS256","ES256"})
Публичные ключи в конфиге или файлеAUTH-5keyfunc.NewDefault из jwks_uri
Вручную обновлять JWK SetAUTH-5keyfunc делает rotation автоматически
AuthError (невалидный JWT) → 403AUTH-6AuthError → 401 всегда
ForbiddenError (нет прав) → 401AUTH-6ForbiddenError → 403 всегда
Raw-токен в context.ContextAUTH-4только разобранный *Principal
Проверка токена внутри handler-функцииAUTH-1AuthN-middleware на роутере

Куда дальше

  • Где какая проверка делается — Gateway vs BFF vs Domain Handler, три слоя аутентификации и авторизации.
  • RBAC: маппинг ролей — extractRoles из realm_access.roles (Keycloak) и scope (OAuth2), RequireRoles на chi-группах.
  • ABAC: владение ресурсом — AccessPolicy в core/<aggregate>/, проверка order.CustomerID == principal.Sub в Handler.
  • Service-to-service — Client Credentials Flow и mTLS для внутренних вызовов.
  • PII и секреты — envconfig из env/Vault, что нельзя в error.Error() и slog.
  • Аудит admin-команд — audit.Logger-порт, обязательная запись в *_audit_log.
  • Хранение токенов на клиенте — HttpOnly + Secure + SameSite cookie в BFF, refresh rotation.
  • Идемпотентность — RequireIdempotencyKey на money-командах.