Опирается на правила: 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 с нужными атрибутами; в тело ответа токен не попадает.

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: trueJavaScript не читает document.cookie. Защита от XSS.
Secure: trueCookie отправляется только по HTTPS. Защита от перехвата в сети.
SameSite: LaxCross-site POST не несёт cookie. На cross-site GET — несёт (приемлемо).
SameSite: StrictCookie не отправляется ни на каких 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-20http.SetCookie с HttpOnly: true
Cookie без HttpOnly: trueAUTH-20JavaScript не должен иметь доступа к токену
Cookie без Secure: trueAUTH-20Только HTTPS; без Secure — токен утекает по HTTP
Cookie без SameSiteAUTH-20Минимум http.SameSiteLaxMode
Refresh cookie на Path: "/"AUTH-20Path: "/auth/refresh" — scope строго ограничен
Refresh без rotation — один RT навсегдаAUTH-21При каждом refresh выдаётся новый RT, старый инвалидируется
Не отзывать цепочку при повторном использовании RTAUTH-21tokenStore.RevokeFamily + 401 + очистить cookie
MaxAge отсутствует (session cookie)AUTH-20Явный MaxAge; без него токен живёт до закрытия браузера
Хранение raw RT в context.ContextAUTH-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.