// Команда oidcstub — подставной провайдер OIDC для локального запуска. // // Сервис держит вход через внешнего провайдера, и без него в приложение не // попасть вовсе: секция `[auth]` обязательна, а выдуманные адреса разбираются // как ссылки, но никуда не ведут. Настоящая Authelia — отдельный сервис, который // надо развернуть и настроить, а ключи боевого провайдера на машине разработчика // лежать не должны. // // Заглушка отвечает на `/authorize`, `/token` и `/userinfo`, и этого хватает: // `user_info_url` — обязательный ключ конфига, а при заполненном адресе сведений // библиотека хранилища берёт их обычным запросом с предъявленным токеном и // `id_token` не смотрит вовсе. Поэтому здесь нет ни ключей подписи, ни документа // обнаружения. // // Проверок она не делает никаких — ни секрета клиента, ни проверочного кода // PKCE, ни выданного токена. Вход у неё один и заранее известный: она нужна, // чтобы дойти до куки сессии, а не чтобы изображать провайдера. По той же // причине слушает она только петлевой адрес. // // Запуск: // // 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", "", "Почта вошедшего; пустое значение даёт @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) } }