Опирается на правила:
AUTH-20,AUTH-21из Auth Patterns → раздел 9. Хранение токенов на клиенте.
Важно знать
http.Cookie{HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode}— обязательная тройка для любого auth-cookie.localStorageзапрещён: XSS-уязвимость, любой скрипт на странице читает без ограничений.- Refresh-cookie ограничивается
Path: "/auth/refresh"— не виден на остальных API-запросах.- При каждом
/auth/refreshстарый RT инвалидируется, выдаётся новый (AUTH-21— rotation обязательна).- Повторное использование старого RT — признак компрометации: BFF отзывает всю цепочку через
tokenStore.RevokeFamily.- Access token хранится в cookie с
Path: "/"иMaxAgeне более 15 минут.- Refresh cookie —
SameSite: http.SameSiteStrictMode, path строго/auth/refresh.- Ошибки — значения:
&apperr.AuthError{Reason: "..."}черезhttperr.Write; panic и голыйhttp.Errorзапрещены.
localStorage — запрещён
AUTH-20. Хранение токена в localStorage делает его доступным любому JavaScript на странице — npm-зависимости с вредоносным кодом, XSS-инъекции, сторонние скрипты аналитики. Cookie с HttpOnly: true недоступен через document.cookie вовсе.
Правильный путь — выдавать токен исключительно через http.SetCookie с нужными атрибутами; в тело ответа токен не попадает.
Callback: выдача cookie после OIDC
BFF получает authorization code, обменивает его на токены у IdP и кладёт результат в два cookie с разными scope.
// adapters/in/http/bff/auth_handler.go
func (h *AuthHandler) Callback(w http.ResponseWriter, r *http.Request) {
tokens, err := h.oidc.Exchange(r.Context(), r.URL.Query().Get("code"))
if err != nil {
httperr.Write(w, r, &apperr.AuthError{Reason: "code exchange failed"})
return
}
// AUTH-20: access token — HttpOnly, короткоживущий, весь сайт
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: tokens.AccessToken,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
MaxAge: int((15 * time.Minute).Seconds()),
})
// AUTH-20: refresh token — строже по SameSite и Path
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: tokens.RefreshToken,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
Path: "/auth/refresh", // не виден на /orders, /products и т.д.
MaxAge: int((7 * 24 * time.Hour).Seconds()),
})
http.Redirect(w, r, "/", http.StatusFound)
}
Атрибуты cookie и что каждый даёт:
| Атрибут | Защита |
|---|---|
HttpOnly: true | JavaScript не читает document.cookie. Защита от XSS. |
Secure: true | Cookie отправляется только по HTTPS. Защита от перехвата в сети. |
SameSite: Lax | Cross-site POST не несёт cookie. На cross-site GET — несёт (приемлемо). |
SameSite: Strict | Cookie не отправляется ни на каких cross-site запросах (для refresh жёстче). |
Path: "/auth/refresh" | Refresh-cookie не приходит на /orders, /customers, /products. |
MaxAge | Явное время жизни; без него — session cookie, сброс при закрытии браузера. |
Refresh token rotation
AUTH-21. Каждый POST /auth/refresh выдаёт новый AT + новый RT; старый RT инвалидируется в IdP (через rotation-политику Keycloak или явным отзывом).
// adapters/in/http/bff/auth_handler.go
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
rtCookie, err := r.Cookie("refresh_token")
if err != nil {
httperr.Write(w, r, &apperr.AuthError{Reason: "refresh token missing"})
return
}
tokens, err := h.oidc.Refresh(r.Context(), rtCookie.Value)
if err != nil {
// AUTH-21: ошибка обмена — возможная компрометация, отзываем всю цепочку
h.tokenStore.RevokeFamily(r.Context(), rtCookie.Value)
http.SetCookie(w, clearCookie("refresh_token", "/auth/refresh"))
httperr.Write(w, r, &apperr.AuthError{Reason: "refresh failed"})
return
}
// AUTH-20: новый access token
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: tokens.AccessToken,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
Path: "/",
MaxAge: int((15 * time.Minute).Seconds()),
})
// AUTH-21: новый RT, старый IdP уже пометил как использованный
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: tokens.RefreshToken,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
Path: "/auth/refresh",
MaxAge: int((7 * 24 * time.Hour).Seconds()),
})
}
func clearCookie(name, path string) *http.Cookie {
return &http.Cookie{
Name: name,
Value: "",
HttpOnly: true,
Secure: true,
Path: path,
MaxAge: -1,
}
}
Если h.oidc.Refresh вернул ошибку при валидном RT — скорее всего, кто-то ранее уже использовал этот RT (attacker или race condition при параллельных вкладках). Правильная реакция: RevokeFamily + очистить cookie + вернуть 401. Легитимный пользователь пройдёт повторный login.
Регистрация роутов в chi
// adapters/in/http/router.go
r := chi.NewRouter()
r.Use(middleware.AuthN(jwtValidator)) // AUTH-1: аутентификация на edge
// публичные auth-роуты — без RequireRoles
r.Get("/auth/callback", authHandler.Callback)
r.Post("/auth/refresh", authHandler.Refresh)
r.Post("/auth/logout", authHandler.Logout)
r.Group(func(r chi.Router) {
r.Use(middleware.RequireRoles("customer")) // AUTH-2: RBAC
r.Post("/orders", orderHandler.CreateOrder)
r.Get("/orders/{id}", orderHandler.GetOrder)
})
AuthN-middleware читает cookie session вместо заголовка Authorization на BFF-роутере; для downstream API-сервисов BFF добавляет Authorization: Bearer самостоятельно.
// adapters/in/http/middleware/authn.go (BFF-вариант)
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) {
cookie, err := r.Cookie("session")
if err != nil {
// публичные роуты пропускаем без principal в контексте
next.ServeHTTP(w, r)
return
}
principal, err := v.Validate(cookie.Value)
if err != nil {
httperr.Write(w, r, &apperr.AuthError{Reason: "invalid session"})
return
}
ctx := context.WithValue(r.Context(), principalKey, principal)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
Logout — явная очистка
// adapters/in/http/bff/auth_handler.go
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if rt, err := r.Cookie("refresh_token"); err == nil {
_ = h.oidc.Revoke(r.Context(), rt.Value) // отозвать в IdP
}
http.SetCookie(w, clearCookie("session", "/"))
http.SetCookie(w, clearCookie("refresh_token", "/auth/refresh"))
http.Redirect(w, r, "/", http.StatusFound)
}
MaxAge: -1 в clearCookie — директива браузеру немедленно удалить cookie. Без явного logout cookie живёт до MaxAge; не очищать при logout — антипаттерн.
BFF как прокси к downstream
BFF читает токен из cookie, добавляет Authorization: Bearer для вызова domain-сервисов:
// adapters/out/order/client.go
type OrderClient struct {
http *http.Client
baseURL string
}
func (c *OrderClient) CreateOrder(ctx context.Context, cmd CreateOrderRequest) (OrderID, error) {
principal := security.PrincipalFrom(ctx)
// principal.RawToken — raw JWT, сохранённый в контексте AuthN-middleware
body, _ := json.Marshal(cmd)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/orders", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+principal.RawToken)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return "", &apperr.GatewayError{System: "order-svc", Op: "create-order", Err: err}
}
defer resp.Body.Close()
// ... decode
}
principal.RawToken — сохраняется в контексте в AuthN-middleware рядом с *Principal; downstream-сервис валидирует подпись самостоятельно.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Токен в теле ответа ({"access_token": "..."}) для браузерного клиента | AUTH-20 | http.SetCookie с HttpOnly: true |
Cookie без HttpOnly: true | AUTH-20 | JavaScript не должен иметь доступа к токену |
Cookie без Secure: true | AUTH-20 | Только HTTPS; без Secure — токен утекает по HTTP |
Cookie без SameSite | AUTH-20 | Минимум http.SameSiteLaxMode |
Refresh cookie на Path: "/" | AUTH-20 | Path: "/auth/refresh" — scope строго ограничен |
| Refresh без rotation — один RT навсегда | AUTH-21 | При каждом refresh выдаётся новый RT, старый инвалидируется |
| Не отзывать цепочку при повторном использовании RT | AUTH-21 | tokenStore.RevokeFamily + 401 + очистить cookie |
MaxAge отсутствует (session cookie) | AUTH-20 | Явный MaxAge; без него токен живёт до закрытия браузера |
Хранение raw RT в context.Context | AUTH-4 | В контексте хранится *Principal; RT — только в cookie |
Куда дальше
- JWT validation — как BFF и domain-сервис валидируют подпись токена через
golang-jwt/jwt/v5+ JWKS. - Где какая проверка делается — gateway читает cookie, конвертирует в
Bearer; domain-сервис не видит cookie вовсе. - PII и секреты — токены = секреты: не в логах
slog, не вerror.Error(). - Service-to-service — S2S не использует HTTP cookie;
oauth2.TokenSource(Client Credentials) или mTLS. - Идемпотентность — money-команды из BFF требуют
Idempotency-Keyв заголовке. - RBAC: маппинг ролей —
RequireRolesна chi-группе; как роли извлекаются из JWT-claims. - ABAC: владение ресурсом — domain-сервис проверяет
order.CustomerID == principal.Sub. - Аудит admin-команд — admin обходит ABAC, но каждое действие пишется в
*_audit_log.