Опирается на правила:
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кеширует ключи и обновляет их автоматически — вручную распаковывать запрещено.- Невалидная подпись или просроченный
exp→ 401, не 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 Forbidden | JWT валиден, но роль не подходит: 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-парсинг — это переизобретение этого кода с неизбежными пропусками.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Самописный парсинг подписи JWT | AUTH-4 | jwt.Parse + v.jwks.Keyfunc |
jwt.Parse без WithExpirationRequired() | AUTH-4 | опция обязательна |
alg: none не отклоняется | AUTH-4 | WithValidMethods([]string{"RS256","ES256"}) |
| Публичные ключи в конфиге или файле | AUTH-5 | keyfunc.NewDefault из jwks_uri |
| Вручную обновлять JWK Set | AUTH-5 | keyfunc делает rotation автоматически |
AuthError (невалидный JWT) → 403 | AUTH-6 | AuthError → 401 всегда |
ForbiddenError (нет прав) → 401 | AUTH-6 | ForbiddenError → 403 всегда |
Raw-токен в context.Context | AUTH-4 | только разобранный *Principal |
| Проверка токена внутри handler-функции | AUTH-1 | AuthN-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-командах.