package http import ( "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" "net/http/httptest" "net/url" "strings" "sync" "time" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tools/router" "github.com/pocketbase/pocketbase/tools/security" pbrepo "git.vakhrushev.me/av/transcriber/internal/adapter/repo/pocketbase" ) const ( // SessionCookieName — имя куки сессии. Имя нормативно: его смена молча // выкидывает всех вошедших. SessionCookieName = "transcriber_session" // stateCookieName — носитель состояния и проверочного кода PKCE. Живёт // один вход и убирается на возврате, каким бы тот ни был. stateCookieName = "transcriber_login" // stateCookieMaxAge — потолок времени на вход у провайдера. Дольше носитель // не нужен, а вечный носитель надолго фиксирует состояние. stateCookieMaxAge = 10 * 60 // exchangeTimeout — потолок обмена кода у провайдера. Без него молчащий // провайдер держит обработчик возврата открытым неограниченно долго, и «медленный» // становится неотличим от «отказал». exchangeTimeout = 15 * time.Second ) // AuthHandler ведёт вход, возврат от провайдера и выход. // // Разбор ответа провайдера остаётся за хранилищем — решение от 2026-08-11. // Обмен кода библиотека наружу не отдаёт: он живёт за её собственным адресом, // поэтому обработчик возврата зовёт этот адрес внутри процесса, через её же // роутер. Цена петли принята решением владельца от 2026-08-12: взамен учётные // записи заводит хранилище и они видны в панели. type AuthHandler struct { app core.App logger *slog.Logger authURL string redirectURL string clientID string secureCookie bool // storageMux — роутер хранилища, через который идёт обмен кода. Собирается // один раз: сборка вешает обработчики на само приложение и без // идентификатора, поэтому повторная не заменяет прежние, а добавляет к ним. // Собранный на каждый вход, он копил бы их без предела — и копил бы по // запросу анонима, потому что обмен исполняется раньше обращения к // провайдеру. storageMux http.Handler storageMuxOnce sync.Once storageMuxErr error } type AuthHandlerConfig struct { AuthURL string RedirectURL string ClientID string SecureCookie bool } func NewAuthHandler(app core.App, cfg AuthHandlerConfig, logger *slog.Logger) *AuthHandler { if logger == nil { logger = slog.Default() } return &AuthHandler{ app: app, logger: logger, authURL: cfg.AuthURL, redirectURL: cfg.RedirectURL, clientID: cfg.ClientID, secureCookie: cfg.SecureCookie, } } // Register вешает адреса входа вне пространства `/api`: оно поделено с // собственными адресами хранилища. func (h *AuthHandler) Register(r *router.Router[*core.RequestEvent]) { // Продление сессии закрывается на всём роутере: адрес приносит хранилище // своим, и перехватить его можно только слоем. r.Bind(BlockSessionRefresh()) r.GET("/auth/login", h.Login) r.GET("/auth/callback", h.Callback) // Выход берёт POST намеренно: по GET его срабатывание уносится переходом по // чужой ссылке. // // Слой предъявления нужен и здесь: без него выход не знает, чью сессию // обесценивать, — он убрал бы куку и отчитался успехом, оставив унесённое // значение годным. Требования сессии при этом нет: выход без неё убирает // куку и молчит. r.POST("/auth/logout", h.Logout).Bind(SessionFromCookie()) } // Login уводит человека к провайдеру, запомнив состояние и проверочный код // PKCE у браузера. func (h *AuthHandler) Login(e *core.RequestEvent) error { state := security.RandomString(32) verifier := security.RandomString(43) e.SetCookie(&http.Cookie{ Name: stateCookieName, Value: state + ":" + verifier, Path: "/", MaxAge: stateCookieMaxAge, HttpOnly: true, Secure: h.secureCookie, SameSite: http.SameSiteLaxMode, }) query := url.Values{} query.Set("response_type", "code") query.Set("client_id", h.clientID) query.Set("redirect_uri", h.redirectURL) query.Set("scope", "openid profile email") query.Set("state", state) query.Set("code_challenge", security.S256Challenge(verifier)) query.Set("code_challenge_method", "S256") separator := "?" if strings.Contains(h.authURL, "?") { separator = "&" } return e.Redirect(http.StatusFound, h.authURL+separator+query.Encode()) } // Callback принимает возврат от провайдера, сверяет состояние и меняет код на // сессию средствами хранилища. func (h *AuthHandler) Callback(e *core.RequestEvent) error { // Носитель убирается всегда — и на успехе, и на отказе, — и убирается // **до** записи ответа. Отложенная уборка не работает вовсе: заголовки // фиксируются в момент, когда ответ начинают писать, и позднейшая правка их // карты до браузера не доезжает. Состояние одноразовое ровно этим: пока // носитель жив, переигранный возврат проходит сверку. h.clearStateCookie(e) query := e.Request.URL.Query() // Всё, что ниже до обмена, — негодный ввод от пришедшего, а не поломка // сервиса: владельцу разбирать нечего, и уровень здесь отладочный. Иначе // обычный отказ человека у провайдера стал бы неотличим от «провайдер лежит». if providerError := query.Get("error"); providerError != "" { h.logger.Debug("Login rejected by provider", "reason", knownProviderError(providerError), "capability", "access", "transport", "http") return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"}) } state, verifier, err := h.readStateCookie(e) if err != nil { h.logger.Debug("Login state is missing or malformed", "error", err, "capability", "access", "transport", "http") return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"}) } if query.Get("state") != state { h.logger.Debug("Login state mismatch", "capability", "access", "transport", "http") return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"}) } code := query.Get("code") if code == "" { h.logger.Debug("Provider returned no code", "capability", "access", "transport", "http") return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"}) } token, err := h.exchange(e.Request.Context(), code, verifier) if err != nil { // Отказ обмена — уже про сервис и его связь с провайдером, поэтому // уровень выше. Код провайдера в журнал не идёт: он и есть предъявитель // входа. h.logger.Error("Failed to exchange provider code", "error", err, "capability", "access", "transport", "http") return e.JSON(http.StatusUnauthorized, map[string]string{"error": "Войти не удалось"}) } h.setSessionCookie(e, token) return e.Redirect(http.StatusFound, "/") } // Logout обесценивает выданные учётной записи сессии и убирает куку. // // Порядок обязателен: сперва обесценивание, потом уборка. При обратном порядке // выход, разошедшийся с одновременным входом, оставил бы годную сессию, а // человек был бы уверен, что вышел. func (h *AuthHandler) Logout(e *core.RequestEvent) error { if e.Auth != nil { // Ключ токенов обновляется у свежей записи: между чтением и записью // могла пройти чужая правка, и полное сохранение устаревшей записи // затёрло бы её. record, err := h.app.FindRecordById(e.Auth.Collection().Id, e.Auth.Id) if err != nil { h.logger.Error("Failed to load account for logout", "error", err, "transport", "http") return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Выйти не удалось"}) } record.RefreshTokenKey() if err := h.app.Save(record); err != nil { h.logger.Error("Failed to revoke sessions", "error", err, "transport", "http") return e.JSON(http.StatusInternalServerError, map[string]string{"error": "Выйти не удалось"}) } } h.clearSessionCookie(e) return e.JSON(http.StatusOK, map[string]string{"status": "ok"}) } // exchange зовёт собственный адрес хранилища внутри процесса. По сети запрос не // идёт: роутер поднимается тот же, что обслуживает внешние запросы. func (h *AuthHandler) exchange(ctx context.Context, code, verifier string) (string, error) { ctx, cancel := context.WithTimeout(ctx, exchangeTimeout) defer cancel() body, err := json.Marshal(map[string]string{ "provider": pbrepo.ProviderName, "code": code, "codeVerifier": verifier, "redirectURL": h.redirectURL, }) if err != nil { return "", fmt.Errorf("failed to build exchange request: %w", err) } request, err := http.NewRequestWithContext( ctx, http.MethodPost, "/api/collections/users/auth-with-oauth2", strings.NewReader(string(body)), ) if err != nil { return "", fmt.Errorf("failed to build exchange request: %w", err) } request.Header.Set("Content-Type", "application/json") handler, err := h.storageHandler() if err != nil { return "", err } recorder := httptest.NewRecorder() handler.ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { // Тело ответа наружу не выносится: в нём приезжает описание отказа // провайдера, а оно принадлежит журналу, а не человеку. return "", fmt.Errorf("storage rejected the exchange with code %d", recorder.Code) } var response struct { Token string `json:"token"` } if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { return "", fmt.Errorf("failed to read exchange response: %w", err) } if response.Token == "" { return "", errors.New("exchange response carries no session") } return response.Token, nil } // knownProviderError приводит причину отказа к перечню известных. // // Значение приходит строкой запроса и целиком задаётся тем, кто её шлёт: без // приведения аноним пишет в журнал что угодно и сколько угодно — предел один, // размер заголовков. Журнал же единственное место, где наблюдаются инварианты о // молчаливой потере задачи, и вытеснять его чужим текстом нельзя. // // Приём тот же, каким расширение записи приводится к перечню форматов. func knownProviderError(value string) string { switch value { case "access_denied", "invalid_request", "invalid_scope", "server_error", "temporarily_unavailable", "unauthorized_client", "unsupported_response_type", "interaction_required", "login_required", "consent_required": return value default: return "other" } } // storageHandler собирает роутер хранилища один раз и отдаёт его всем // последующим обменам. func (h *AuthHandler) storageHandler() (http.Handler, error) { h.storageMuxOnce.Do(func() { router, err := apis.NewRouter(h.app) if err != nil { h.storageMuxErr = fmt.Errorf("failed to build storage router: %w", err) return } mux, err := router.BuildMux() if err != nil { h.storageMuxErr = fmt.Errorf("failed to build storage router: %w", err) return } h.storageMux = mux }) return h.storageMux, h.storageMuxErr } func (h *AuthHandler) readStateCookie(e *core.RequestEvent) (state, verifier string, err error) { cookie, err := e.Request.Cookie(stateCookieName) if err != nil { return "", "", fmt.Errorf("login state cookie is missing: %w", err) } state, verifier, found := strings.Cut(cookie.Value, ":") if !found || state == "" || verifier == "" { return "", "", errors.New("login state cookie is malformed") } return state, verifier, nil } func (h *AuthHandler) setSessionCookie(e *core.RequestEvent, token string) { e.SetCookie(&http.Cookie{ Name: SessionCookieName, Value: token, Path: "/", MaxAge: pbrepo.SessionDuration, HttpOnly: true, Secure: h.secureCookie, SameSite: http.SameSiteLaxMode, }) } func (h *AuthHandler) clearSessionCookie(e *core.RequestEvent) { e.SetCookie(&http.Cookie{ Name: SessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: h.secureCookie, SameSite: http.SameSiteLaxMode, }) } func (h *AuthHandler) clearStateCookie(e *core.RequestEvent) { e.SetCookie(&http.Cookie{ Name: stateCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: h.secureCookie, SameSite: http.SameSiteLaxMode, }) }