Files
transcriber/cmd/oidcstub/main.go
T
av daa4c3b6e4 oidcstub: записано решение не заводить заглушке своих тестов
- решение владельца 2026-08-15: пакет в образ не едет, отказ громкий,
  а путь входа проверен подставным провайдером в тестах транспорта
2026-08-15 20:18:58 +03:00

179 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Команда oidcstub — подставной провайдер OIDC для локального запуска.
//
// Сервис держит вход через внешнего провайдера, и без него в приложение не
// попасть вовсе: секция `[auth]` обязательна, а выдуманные адреса разбираются
// как ссылки, но никуда не ведут. Настоящая Authelia — отдельный сервис, который
// надо развернуть и настроить, а ключи боевого провайдера на машине разработчика
// лежать не должны.
//
// Заглушка отвечает на `/authorize`, `/token` и `/userinfo`, и этого хватает:
// `user_info_url` — обязательный ключ конфига, а при заполненном адресе сведений
// библиотека хранилища берёт их обычным запросом с предъявленным токеном и
// `id_token` не смотрит вовсе. Поэтому здесь нет ни ключей подписи, ни документа
// обнаружения.
//
// Проверок она не делает никаких — ни секрета клиента, ни проверочного кода
// PKCE, ни выданного токена. Вход у неё один и заранее известный: она нужна,
// чтобы дойти до куки сессии, а не чтобы изображать провайдера. По той же
// причине слушает она только петлевой адрес.
//
// Своих тестов у заглушки нет, и это решение владельца от 2026-08-15, а не
// недосмотр. Разбор `redirect_uri` и выбор метода машиной поэтому не
// проверяются: регресс в них ловится руками, на первом же локальном входе.
// Довод — цена отказа: в образ этот пакет не едет, читатель у него один, а сам
// отказ громкий и виден сразу. Путь входа при этом проверен и без неё — своим
// подставным провайдером в `internal/controller/http/login_test.go`.
//
// Запуск:
//
// go run ./cmd/oidcstub
// go run ./cmd/oidcstub -sub local-2
//
// Другое значение `-sub` заводит второго вошедшего, и почта переезжает вместе с
// ним: без своей почты второй вход отвергается вовсе. Хранилище ищет учётную
// запись сперва по признаку провайдера, а не найдя — по адресу почты, и на
// найденной записи признак уникален. Второй `sub` при общей почте пришёл бы к той
// же записи и получил бы отказ уникальности — вход отвечал бы 401, а причина
// осталась бы в журнале хранилища строкой про связь.
package main
import (
"encoding/json"
"flag"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
"time"
)
// authCode — код, который заглушка отдаёт на возврате. Значение постоянное:
// одноразовость кода держит провайдер, а здесь её изображать не для кого.
const authCode = "local-code"
// accessToken — то, что уезжает в обмен и приходит обратно заголовком запроса
// сведений о вошедшем. Заглушка его не сверяет.
const accessToken = "local-token"
// readHeaderTimeout — потолок чтения заголовков. Заглушка стоит на петлевом
// адресе, но сервер без единого срока держит подвисшее соединение вечно.
const readHeaderTimeout = 5 * time.Second
func main() {
port := flag.Int("port", 9000, "Порт заглушки")
sub := flag.String("sub", "local-1", "Идентификатор вошедшего: разные значения дают разных владельцев записей")
name := flag.String("name", "Локальный", "Имя вошедшего")
email := flag.String("email", "", "Почта вошедшего; пустое значение даёт <sub>@example.com")
flag.Parse()
// Почта выводится из `sub`, а не стоит своим умолчанием: общая почта у двух
// разных `sub` приводит второй вход к первой записи, а признак провайдера на
// ней уникален — второй вошедший получал бы 401 вместо своей учётной записи.
// Обещание «разные `-sub` дают разных владельцев» держится этой строкой.
if *email == "" {
*email = *sub + "@example.com"
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
mux := http.NewServeMux()
mux.HandleFunc("/authorize", authorize(logger))
mux.HandleFunc("/token", token(logger))
mux.HandleFunc("/userinfo", userInfo(logger, *sub, *name, *email))
// Петлевой адрес, а не все — заглушка пускает кого угодно кем угодно, и в
// чужой сети это дыра, а не удобство.
addr := fmt.Sprintf("127.0.0.1:%d", *port)
server := &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: readHeaderTimeout,
}
logger.Info("oidc stub started", "addr", addr, "sub", *sub)
if err := server.ListenAndServe(); err != nil {
logger.Error("oidc stub stopped", "error", err)
os.Exit(1)
}
}
// authorize уводит браузер обратно на адрес возврата с готовым кодом.
//
// Настоящий провайдер спросил бы здесь имя и пароль; заглушка не спрашивает
// ничего и возвращает сразу — в этом вся её работа.
func authorize(logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
target, err := url.Parse(query.Get("redirect_uri"))
if err != nil || target.Host == "" {
logger.Error("authorize rejected", "reason", "redirect_uri is missing or malformed")
http.Error(w, "redirect_uri не задан или не разбирается", http.StatusBadRequest)
return
}
// Состояние возвращается тем же значением, каким пришло: сервис сверяет
// его со своей кукой и без совпадения отвергает возврат.
back := target.Query()
back.Set("code", authCode)
if state := query.Get("state"); state != "" {
back.Set("state", state)
}
target.RawQuery = back.Encode()
logger.Info("authorize passed", "redirect_host", target.Host)
http.Redirect(w, r, target.String(), http.StatusFound)
}
}
// token отдаёт токен в обмен на код. Ни код, ни секрет клиента, ни проверочный
// код PKCE не сверяются.
func token(logger *slog.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "обмен идёт методом POST", http.StatusMethodNotAllowed)
return
}
// `id_token` не отдаётся намеренно: подписать его нечем, а библиотека
// хранилища его и не смотрит — `user_info_url` стоит в перечне
// обязательных ключей конфига, и без него сервис не поднимается вовсе.
writeJSON(logger, w, map[string]any{
"access_token": accessToken,
"token_type": "Bearer",
"expires_in": 3600,
})
}
}
// userInfo отдаёт сведения о вошедшем.
//
// `email_verified` обязано быть истинным: без него библиотека адрес почты в
// учётную запись не запишет, и запись заведётся без него.
func userInfo(logger *slog.Logger, sub, name, email string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
writeJSON(logger, w, map[string]any{
"sub": sub,
"name": name,
"preferred_username": sub,
"email": email,
"email_verified": true,
})
}
}
// writeJSON отвечает разметкой JSON. Отказ записи идёт в журнал: ответ к этому
// моменту уже начат, и сказать о нём спрашивающему нечем.
func writeJSON(logger *slog.Logger, w http.ResponseWriter, body map[string]any) {
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(body); err != nil {
logger.Error("response write failed", "error", err)
}
}