Читать в markdown — один файл, без вёрстки — это и есть машинная версия страницы · In English · Инструкция для человека
Вход ZERNO — инструкция для агента
Версия контракта: 8. Единый вход для сайта по OpenID Connect. Агент получает от человека бриф zerno_login с ключами и делает интеграцию сам.
Тебе передали JSON-бриф zerno_login. Прочитай этот документ целиком,
сделай интеграцию, затем выполни раздел «Проверка» и отчитайся человеку.
1. Адреса
issuer https://id.zerno.one
discovery https://id.zerno.one/.well-known/openid-configuration
authorize https://id.zerno.one/oauth/v2/authorize
token https://id.zerno.one/oauth/v2/token
userinfo https://id.zerno.one/oidc/v1/userinfo
logout https://id.zerno.one/oidc/v1/end_session
scope поле scope из брифа — подставлять целиком, ничего не дописывая
2. Переменные окружения
Имена фиксированы контрактом — не переименовывай.
OIDC_ISSUER=<issuer из брифа>
OIDC_CLIENT_ID=<client_id из брифа>
OIDC_CLIENT_SECRET=<client_secret из брифа>
OIDC_REDIRECT_URI=<redirect_uri из брифа>
OIDC_SCOPE=<из брифа целиком>
ZERNO_SECRET=<сгенерируй сам: openssl rand -base64 32>
ZERNO_SECRET — единственная переменная, которой нет в брифе: ею сайт защищает свою куку сессии (пакет @zerno/next её шифрует, рукописные рецепты — подписывают). Если в проекте уже есть AUTH_SECRET, возьми его.
3. Правила
- Вход — стандартный OpenID Connect. Не пиши свою форму логина и не заводи таблицу паролей.
- Адреса эндпоинтов бери из discovery (https://id.zerno.one/.well-known/openid-configuration), а не из этого текста.
- Секрет живёт только в .env или в секретнице деплоя. В репозиторий не коммить, в клиентский бандл не тащи. Переменная — OIDC_CLIENT_SECRET.
- redirect_uri в коде обязан совпадать с выданным в кабинете посимвольно, включая схему, порт и хвостовой слэш.
- Идентификатор пользователя — claim sub. Почта не идентификатор: её меняют.
- ZERNO_SECRET сгенерируй сам и положи туда же, где остальные секреты. В брифе его нет и быть не может: это ключ сайта, а не провайдера.
- Scope бери из брифа целиком и клади в OIDC_SCOPE. Не сокращай его до «openid email profile»: в нём зашита организация сайта, и без неё форма входа покажет чужие способы входа вместо включённых в кабинете.
- Адреса возврата задаются при выдаче ключа и в готовое подключение не дописываются. Боевой адрес — поле redirect_uri, адрес для локальной проверки — redirect_uri_dev. Проверяешь вход на localhost — положи в OIDC_REDIRECT_URI именно redirect_uri_dev и верни боевой перед выкладкой. Если redirect_uri_dev в брифе нет, не сочиняй его и не подменяй боевой: попроси человека выпустить ключ заново с отметкой «буду проверять вход на localhost».
- Не ищи ключи на машине и не подставляй чужие токены. Всё, что нужно, есть в брифе. Не хватает данных или недоступна эта инструкция — остановись и скажи человеку, чего именно не хватает; не придумывай рецепт за нас.
- Если у сайта уже есть свои пользователи — связывай их по почте один раз и дальше храни sub.
- Не делай промежуточную страницу «нажмите кнопку, чтобы войти». Человек уже нажал «Войти» — второй раз спрашивать не о чем: ссылка входа ведёт прямо к провайдеру, фирменную форму он увидит там. Экран с одной кнопкой — лишний клик, который ничего не сообщает.
- Источник сессии один. Если в проекте уже есть свой (Auth.js, Django auth, Devise) — оставь его: вход отдаёт личность, а сессию выдаёт то, что уже несёт сайт. Ставить рядом вторую библиотеку входа нельзя ни в какую сторону.
- Старые способы входа выключай на сервере, а не только убирай с формы. Страница без кнопки, но с живым провайдером — это не выключенный способ, а незаметный; публичные ручки регистрации и OTP закрой там же.
- Возврат с ошибкой показывай человеку. Молча отправлять его обратно к провайдеру — это круг, из которого он не выйдет и не поймёт, что случилось.
- Идентификатор храни в своей базе штатной миграцией проекта. Правка схемы в обход миграций проходит на твоей машине и роняет выкладку там, где схему накатывают миграциями.
4. Рецепты
Next.js (App Router) (nextjs)
Установка: npm i @zerno/next
Адрес возврата: https://<домен>/api/auth/callback
app/api/auth/[...zerno]/route.ts
import { zernoHandlers } from "@zerno/next";
// Вход, возврат и выход — один роут. Он же обслуживает адрес возврата с
// хвостом (`/api/auth/callback/zerno`), если такой уже зарегистрирован.
export const { GET, POST } = zernoHandlers();
app/layout.tsx
import { auth, getLoginBadge, getLoginMethods } from "@zerno/next";
import { ZernoProvider } from "@zerno/next/client";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
// Список способов приходит с сервера: включили Яндекс в кабинете — кнопка
// появилась без выкладки сайта. Захардкоженный список даёт кнопку в ошибку.
// Оттуда же приходит подпись «вход от ZERNO» под кнопками — на платном
// тарифе её нет, и тогда это просто null.
const [{ user }, methods, badge] = await Promise.all([
auth(),
getLoginMethods(),
getLoginBadge(),
]);
return (
<html lang="ru">
<body>
<ZernoProvider user={user} methods={methods} badge={badge}>
{children}
</ZernoProvider>
</body>
</html>
);
}
components/AuthButtons.tsx
"use client";
import { SignIn, UserButton, useUser } from "@zerno/next/client";
export function AuthButtons() {
return useUser() ? <UserButton /> : <SignIn />;
}
middleware.ts
import { zernoMiddleware } from "@zerno/next/middleware";
// Список защищённого — единственное, что здесь решает сайт.
export default zernoMiddleware({ protect: ["/app", "/settings"] });
export const config = { matcher: ["/((?!_next|favicon.ico).*)"] };
- Пакет
@zerno/nextуже делает PKCE, сверкуstate, шифрование куки, обновление токена и защиту маршрутов. Не пиши это руками рядом с ним и не подключай вторую библиотеку входа — двух источников сессии не бывает. - OIDC_REDIRECT_URI — полный внешний адрес сайта. Из него же берётся адрес для всех редиректов: за прокси в контейнере
request.urlпоказывает внутренний хост, и после успешного входа человек уезжает на 0.0.0.0:3000. Не собирай редиректы из адреса запроса. - Адрес возврата не подгоняется под код: роут — catch-all, он обслуживает и
/api/auth/callback, и уже зарегистрированный/api/auth/callback/zerno. - Кто вошёл: на сервере
await auth(), на клиентеuseUser(). Куку не разбирай — в ней шифром лежат токены провайдера. - Выход — только POST.
<UserButton />рисует его формой; своя кнопка —<SignOutButton />. Ссылкой выход срабатывает от предзагрузчика браузера и от чужой страницы с<img src>. - OIDC_SCOPE копируй из брифа целиком: в его хвосте организация сайта, без неё форма входа покажет способы по умолчанию.
- ZERNO_SECRET — длинная случайная строка (
openssl rand -base64 32), ею шифруется кука сессии. Если в проекте уже есть AUTH_SECRET, подойдёт он. - Проверяй вход через
next dev. У продовой сборки кука уходит с флагом Secure, и по http://localhost браузер её не сохранит — вход будет выглядеть сломанным на ровном месте. - Свою форму с логином и паролем не делай и не оставляй — вход целиком на стороне провайдера.
- Своего пользователя связывай с
subиз сессии. Почта не идентификатор.
Django + Authlib (django)
Установка: pip install authlib
Адрес возврата: https://<домен>/auth/callback
settings.py
import os
AUTHLIB_OAUTH_CLIENTS = {
"zerno": {
"client_id": os.environ["OIDC_CLIENT_ID"],
"client_secret": os.environ["OIDC_CLIENT_SECRET"],
"server_metadata_url": os.environ["OIDC_ISSUER"]
+ "/.well-known/openid-configuration",
# Scope целиком из брифа: в нём зашита организация сайта.
"client_kwargs": {"scope": os.environ["OIDC_SCOPE"]},
}
}
accounts/views.py
import os
from authlib.integrations.django_client import OAuth
from django.contrib.auth import get_user_model, login
from django.shortcuts import redirect
oauth = OAuth()
oauth.register("zerno")
def login_start(request):
return oauth.zerno.authorize_redirect(request, os.environ["OIDC_REDIRECT_URI"])
def login_callback(request):
token = oauth.zerno.authorize_access_token(request)
claims = token["userinfo"]
User = get_user_model()
# sub — единственный стабильный идентификатор. Почта может поменяться.
user, _ = User.objects.get_or_create(
username=claims["sub"],
defaults={"email": claims.get("email", "")},
)
login(request, user)
return redirect("/")
urls.py
from django.urls import path
from accounts import views
urlpatterns = [
path("auth/login", views.login_start),
path("auth/callback", views.login_callback),
]
- OIDC_REDIRECT_URI должен совпадать с адресом, выданным в кабинете, посимвольно.
- Пароли в модели пользователя остаются пустыми — их больше никто не проверяет.
Node.js / Express + openid-client (express)
Установка: npm i openid-client express-session
Адрес возврата: https://<домен>/auth/callback
auth.js
import { Issuer, generators } from "openid-client";
const issuer = await Issuer.discover(process.env.OIDC_ISSUER);
const client = new issuer.Client({
client_id: process.env.OIDC_CLIENT_ID,
client_secret: process.env.OIDC_CLIENT_SECRET,
redirect_uris: [process.env.OIDC_REDIRECT_URI],
response_types: ["code"],
});
export function loginStart(req, res) {
const verifier = generators.codeVerifier();
req.session.verifier = verifier;
res.redirect(
client.authorizationUrl({
scope: process.env.OIDC_SCOPE,
code_challenge: generators.codeChallenge(verifier),
code_challenge_method: "S256",
}),
);
}
export async function loginCallback(req, res) {
const params = client.callbackParams(req);
const tokens = await client.callback(process.env.OIDC_REDIRECT_URI, params, {
code_verifier: req.session.verifier,
});
req.session.user = tokens.claims();
res.redirect("/");
}
- Сессионная кука обязана быть httpOnly + secure + sameSite=lax.
- code_verifier хранится в сессии между двумя запросами, иначе обмен кода не пройдёт.
PHP + league/oauth2-client (php)
Установка: composer require league/oauth2-client
Адрес возврата: https://<домен>/auth/callback.php
auth.php
<?php
require 'vendor/autoload.php';
session_start();
$issuer = getenv('OIDC_ISSUER');
$provider = new League\OAuth2\Client\Provider\GenericProvider([
'clientId' => getenv('OIDC_CLIENT_ID'),
'clientSecret' => getenv('OIDC_CLIENT_SECRET'),
'redirectUri' => getenv('OIDC_REDIRECT_URI'),
'urlAuthorize' => 'https://id.zerno.one/oauth/v2/authorize',
'urlAccessToken' => 'https://id.zerno.one/oauth/v2/token',
'urlResourceOwnerDetails' => 'https://id.zerno.one/oidc/v1/userinfo',
'scopes' => explode(' ', getenv('OIDC_SCOPE')),
]);
if (!isset($_GET['code'])) {
header('Location: ' . $provider->getAuthorizationUrl());
$_SESSION['oauth2state'] = $provider->getState();
exit;
}
if (empty($_GET['state']) || $_GET['state'] !== ($_SESSION['oauth2state'] ?? null)) {
unset($_SESSION['oauth2state']);
exit('state не совпал');
}
$token = $provider->getAccessToken('authorization_code', ['code' => $_GET['code']]);
$_SESSION['user'] = $provider->getResourceOwner($token)->toArray();
header('Location: /');
- Проверка state обязательна — без неё вход открыт для CSRF.
- Адреса взяты из discovery-документа; если провайдер их поменяет, читай discovery, а не эти строки.
WordPress (плагин «Вход ZERNO ID») (wordpress)
Установка: https://zerno.one/pkg/zerno-id-wp-1.0.1.zip
Адрес возврата: https://<домен>/wp-login.php?action=zerno-callback
wp-config.php
<?php
// Ставится только при необходимости. Плагин работает и без этих строк.
// Аварийный рубильник: плагин выключается целиком, штатный вход по паролю
// снова работает. Нужен, если вход настроили неверно и в админку не попасть.
// define('ZERNO_ID_DISABLE', true);
// Строгий режим: пароль перестаёт быть дверью и для администраторов тоже.
// Без него галочка «выключить пароли» не трогает тех, кто правит сайт —
// иначе неверная настройка запирает сайт от собственного владельца.
// define('ZERNO_ID_STRICT', true);
- Плагин ставится в админке сайта: «Плагины → Добавить новый → Загрузить плагин» и zip по адресу выше. Ни репозиторий, ни доступ по ssh для этого не нужны — у сайта на шаред-хостинге их обычно и нет.
- Кода здесь не пишется: клиент входа — готовый плагин. Он делает PKCE, сверку state, обмен кода, userinfo и выход у провайдера. Не подключай рядом вторую библиотеку входа и не переписывай wp-login.php.
- Ключи вводятся в «Настройки → Вход ZERNO», а не в переменные окружения: у сайта на шаред-хостинге их обычно негде задать. Секрет лежит в базе сайта отдельной опцией и на страницы не попадает.
- Адрес возврата у плагина фиксирован формой
…/wp-login.php?action=zerno-callback— его надо вписать в карточку сайта, а не подгонять под уже выданный. Там же регистрируется адрес после выхода: главная страница сайта. - Свой пользователь связывается с человеком по
sub; существующего плагин подхватывает по подтверждённой почте один раз, при первой встрече. Ничего дописывать в базу руками не нужно. - Старый вход выключается галочкой «Выключить вход по паролю» — она работает на сервере, а не прячет поля со страницы. Регистрация и восстановление пароля закрываются вместе с ней.
Любой стек: чистый OIDC authorization code (manual)
Установка: —
Адрес возврата: https://<домен>/auth/callback
flow.http
# 1. Метаданные (все адреса берутся отсюда)
GET https://id.zerno.one/.well-known/openid-configuration
# 2. Отправляем человека на форму входа
GET https://id.zerno.one/oauth/v2/authorize
?response_type=code
&client_id={client_id}
&redirect_uri={redirect_uri}
&scope=<значение scope из брифа, URL-кодированное целиком>
&state=<случайная строка в сессии>
&code_challenge=<S256 от code_verifier>
&code_challenge_method=S256
# 3. Он вернулся на redirect_uri с ?code= и ?state=
# state сверить с сессией, иначе — отказ.
# 4. Меняем код на токены (сервер-сервер, секрет не показываем браузеру)
POST https://id.zerno.one/oauth/v2/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=<code>&redirect_uri={redirect_uri}
&client_id={client_id}&client_secret=<секрет>&code_verifier=<verifier>
# 5. Кто вошёл
GET https://id.zerno.one/oidc/v1/userinfo
Authorization: Bearer <access_token>
- sub из id_token — единственный стабильный ключ пользователя. Почту человек может сменить.
- id_token проверяй подписью по jwks_uri из discovery, а не «на глаз».
5. Проверка
Без этого раздела задача не считается сделанной.
- curl -sS https://id.zerno.one/.well-known/openid-configuration | head — должен вернуться JSON с authorization_endpoint.
- Запусти проект локально и открой страницу входа: должен произойти редирект на https://id.zerno.one, а не 500. Если в брифе есть redirect_uri_dev — на время проверки он и должен стоять в OIDC_REDIRECT_URI.
- Пройди вход и вернись: в сессии должен появиться sub. Покажи человеку, где именно он теперь лежит.
- Открой страницу входа глазами постороннего: на ней не должно остаться ни промежуточного экрана с одной кнопкой, ни старых способов входа рядом с нашим. Одна дверь — иначе человек выберет ту, что привычнее, и ничего не изменится.
- Проверь выход: сессия сайта очищается, повторный вход снова спрашивает подтверждение.
- Скажи человеку одной строкой: какие файлы созданы, какие переменные добавлены, что осталось сделать руками — и верни в OIDC_REDIRECT_URI боевой адрес, если проверял вход на localhost.
6. Что приезжает о человеке
Что приезжает о человеке в id_token и userinfo. Права сайт заявляет в кабинете; человек видит их на экране согласия и вправе снять любую, кроме почты.
| Поле | Что это |
|---|---|
sub | идентификатор человека у тебя. Свой на каждый сайт, навсегда |
email | адрес почты; email_verified — отметка, что он подтверждён |
name | имя и фамилия одной строкой; рядом given_name и family_name |
phone_number | телефон; phone_number_verified говорит, подтверждён ли он |
birthdate | дата рождения, YYYY-MM-DD |
gender | female или male |
picture | адрес фотографии у нас — скачай и сохрани у себя, если нужна |
- Присутствие поля не гарантировано: человек мог не назвать его или отказать в передаче. Пиши код так, чтобы отсутствие поля было обычным делом, а не ошибкой входа.
- Права правятся в кабинете, в разделе входа: «Что сайт узнаёт о человеке». Просить с запасом невыгодно — каждая лишняя строка на экране согласия повод нажать «войти по-другому».
phone_number_verified: falseозначает «человек назвал номер», а не «номер его». Пускать по такому номеру в чужой заказ нельзя.- Профиль живёт у нас и меняется человеком. Если он важен тебе свежим — перечитывай его при входе, а не считай снимок вечным.
- Поставь у себя ссылку на https://id.zerno.one — это кабинет человека: там он правит имя и телефон, видит, каким сайтам что открыл, и закрывает доступ. Без ссылки с сайта эта страница для него не существует, а спрашивать «где мой профиль» он придёт к тебе.
7. Переезд с существующей базы
Как перевести существующую аудиторию сайта на вход, не заводя людей заново и не оставляя их снаружи на время переезда.
- Пароли не переносятся и не нужны: у входа их нет как способа. Человек войдёт кодом на ту же почту и попадёт в свою же учётку.
- Переносится не человек, а связь: вместе с адресом отправь его прежний идентификатор у себя. Он вернётся в токене как claim
legacy_idна первом же входе — по нему найдёшь свою старую строку и не заведёшь второго пользователя. - Перенос делает владелец сайта в кабинете: раздел входа, «Перенести своих людей». Партиями по тысяче — так видно, где остановились.
- Порядок такой: сначала перенос, потом вход на сайте, и только потом выключение старой формы. Обратный порядок оставляет людей снаружи ровно на то время, пока идёт переезд.
- События
user.createdна перенесённых не приходят: тысяча событий в твой обработчик — это не новость, а авария. Сопоставление возвращается ответом на сам перенос.
8. Частые ошибки
- invalid_redirect_uri — Адрес возврата не совпал с выданным. Сверь строку целиком; порт и слэш считаются. Локально годится только redirect_uri_dev из брифа — боевой адрес с localhost работать не будет, и наоборот.
- invalid_client — Не тот client_id или секрет. Секрет показывается один раз — если потерян, человек выпускает новый в кабинете.
- state не совпал — Потерялась сессия между двумя запросами. Обычно причина — куки без sameSite=lax или другой домен.
- нет способов входа — Способы включаются тумблерами в карточке сайта. Агент их включить не может — это делает человек.
- форма входа без кнопок Яндекса и Telegram — В запрос ушёл урезанный scope. Проверь, что OIDC_SCOPE содержит строку из брифа целиком, вместе с хвостом urn:zitadel:iam:org:id:… — он говорит форме, чей это сайт. Если хвост потерян, кнопки не появятся, сколько ни включай их в кабинете.
9. События (делай, только если человек попросил)
События входа приезжают на адрес сайта сами: опрашивать наш API не нужно. Подписка заводится человеком в кабинете, там же он получает секрет подписи.
| Событие | Что значит |
|---|---|
user.created | человек появился впервые — заводи свою строку пользователя |
session.created | вход; любым способом — почтой, отпечатком, провайдером |
user.identifier_verified | подтверждена почта — ей можно доверять |
user.merged | две учётки оказались одним человеком; перевесь связи на новый sub |
user.blocked | хозяин сайта закрыл человеку вход — гаси свою сессию, наша уже закрыта |
user.unblocked | вход открыт обратно |
Заголовки:
X-Zerno-Signature: t=<unix>,v1=<hmac-sha256 от t.тело>
X-Zerno-Event
X-Zerno-Delivery — ключ повтора
Секрет подписи человек берёт в кабинете и кладёт в ZERNO_WEBHOOK_SECRET. В брифе его нет: он показывается один раз при заведении подписки.
// Проверка подписи: X-Zerno-Signature: t=<unix>,v1=<hmac-sha256>
import crypto from "node:crypto";
export function verifyZernoWebhook(rawBody: string, header: string) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = crypto
.createHmac("sha256", process.env.ZERNO_WEBHOOK_SECRET!)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
// Тело: { event, at, tenant_id, sub, application_id, provider }
- Считай подпись по сырому телу запроса, до разбора JSON: перевыпущенный json.dumps даёт другие байты и подпись не сойдётся.
- Проверяй время из подписи (
t=) — иначе перехваченный запрос можно повторить когда угодно. Пять минут расхождения достаточно. - Обработчик обязан быть идемпотентным: доставка повторяется до пяти раз за сутки, и одно и то же событие может прийти дважды. Ключ повтора — заголовок X-Zerno-Delivery.
- Отвечай 2xx сразу и клади событие в свою очередь: ответа ждут десять секунд.
- Почты и телефона в событии нет намеренно. Нужны — спрашивай по sub через userinfo или свою базу.
10. Чего делать не нужно
- Не заводить свою форму с паролем и таблицу
users.password. - Не хранить client_secret в клиентском коде и не логировать его.
- Не менять способы входа (passkey, почта, Яндекс ID, Google, GitHub, Telegram) — это тумблеры в кабинете человека.
- Не выдумывать эндпоинты: всё, чего нет в discovery, не существует.
- Не строить промежуточных экранов вокруг входа: ни «нажмите кнопку», ни своей страницы выбора способа. Способы выбираются на форме провайдера.
- Не подгонять выданный адрес возврата под привычки фреймворка. Если библиотека ходит на свой путь, а выдан другой — обменяй код сам или попроси человека выпустить ключ заново. Чужой redirect_uri провайдер отвергает, и это правильно.
