- решение владельца 2026-08-15: пакет в образ не едет, отказ громкий, а путь входа проверен подставным провайдером в тестах транспорта
179 lines
9.4 KiB
Go
179 lines
9.4 KiB
Go
// Команда 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)
|
||
}
|
||
}
|